From 7f41b3a0437c25eb696a7a6c19732d23d46198fb Mon Sep 17 00:00:00 2001 From: Daniel Chen <305872926+daniel-chen-j@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:04:43 +0100 Subject: [PATCH 1/2] docs: add Zustand cookbook recipe --- examples/cookbook/app/index.tsx | 3 +- .../app/state-management/zustand/TaskList.tsx | 52 +++++ .../zustand/__tests__/TaskList.test.tsx | 48 ++++ .../zustand/__tests__/test-utils.tsx | 33 +++ .../app/state-management/zustand/index.tsx | 7 + .../app/state-management/zustand/state.ts | 36 +++ .../app/state-management/zustand/types.ts | 4 + examples/cookbook/package.json | 3 +- .../12.x/cookbook/state-management/_meta.json | 5 +- .../12.x/cookbook/state-management/zustand.md | 221 ++++++++++++++++++ .../13.x/cookbook/state-management/_meta.json | 5 +- .../13.x/cookbook/state-management/zustand.md | 221 ++++++++++++++++++ .../14.x/cookbook/state-management/_meta.json | 5 +- .../14.x/cookbook/state-management/zustand.md | 221 ++++++++++++++++++ 14 files changed, 859 insertions(+), 5 deletions(-) create mode 100644 examples/cookbook/app/state-management/zustand/TaskList.tsx create mode 100644 examples/cookbook/app/state-management/zustand/__tests__/TaskList.test.tsx create mode 100644 examples/cookbook/app/state-management/zustand/__tests__/test-utils.tsx create mode 100644 examples/cookbook/app/state-management/zustand/index.tsx create mode 100644 examples/cookbook/app/state-management/zustand/state.ts create mode 100644 examples/cookbook/app/state-management/zustand/types.ts create mode 100644 website/docs/12.x/cookbook/state-management/zustand.md create mode 100644 website/docs/13.x/cookbook/state-management/zustand.md create mode 100644 website/docs/14.x/cookbook/state-management/zustand.md diff --git a/examples/cookbook/app/index.tsx b/examples/cookbook/app/index.tsx index 26aa26261..ade46c822 100644 --- a/examples/cookbook/app/index.tsx +++ b/examples/cookbook/app/index.tsx @@ -87,8 +87,9 @@ type Recipe = { const recipes: Recipe[] = [ { id: 1, title: 'Welcome Screen with Custom Render', path: 'custom-render/' }, { id: 2, title: 'Task List with Jotai', path: 'state-management/jotai/' }, + { id: 3, title: 'Task List with Zustand', path: 'state-management/zustand/' }, { - id: 3, + id: 4, title: 'Phone book with\na Variety of Net. Req. Methods', path: 'network-requests/', }, diff --git a/examples/cookbook/app/state-management/zustand/TaskList.tsx b/examples/cookbook/app/state-management/zustand/TaskList.tsx new file mode 100644 index 000000000..224aa926b --- /dev/null +++ b/examples/cookbook/app/state-management/zustand/TaskList.tsx @@ -0,0 +1,52 @@ +import 'react-native-get-random-values'; + +import { nanoid } from 'nanoid'; +import * as React from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; + +import { useTaskStore } from './state'; + +export function TaskList() { + const tasks = useTaskStore((state) => state.tasks); + const newTaskTitle = useTaskStore((state) => state.newTaskTitle); + const setNewTaskTitle = useTaskStore((state) => state.setNewTaskTitle); + const addTask = useTaskStore((state) => state.addTask); + + const handleAddTask = () => { + addTask({ + id: nanoid(), + title: newTaskTitle, + }); + }; + + return ( + + {tasks.map((task) => ( + + {task.title} + + ))} + + {!tasks.length ? No tasks, start by adding one... : null} + + setNewTaskTitle(text)} + /> + + + Add Task + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/examples/cookbook/app/state-management/zustand/__tests__/TaskList.test.tsx b/examples/cookbook/app/state-management/zustand/__tests__/TaskList.test.tsx new file mode 100644 index 000000000..3089426d7 --- /dev/null +++ b/examples/cookbook/app/state-management/zustand/__tests__/TaskList.test.tsx @@ -0,0 +1,48 @@ +import { render, screen, userEvent } from '@testing-library/react-native'; +import * as React from 'react'; + +import { addTask, getAllTasks, useTaskStore } from '../state'; +import { TaskList } from '../TaskList'; +import { Task } from '../types'; +import { renderWithStore } from './test-utils'; + +jest.useFakeTimers(); + +afterEach(() => { + useTaskStore.getState().reset(); +}); + +test('renders an empty task list', async () => { + await render(); + expect(screen.getByText(/no tasks, start by adding one/i)).toBeOnTheScreen(); +}); + +const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread' }]; + +test('renders a to do list with 1 items initially, and adds a new item', async () => { + await renderWithStore(, { + initialState: { + tasks: INITIAL_TASKS, + newTaskTitle: '', + }, + }); + + expect(screen.getByText(/buy bread/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(1); + + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText(/new task/i), 'Buy almond milk'); + await user.press(screen.getByRole('button', { name: /add task/i })); + + expect(screen.getByText(/buy almond milk/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(2); +}); + +test('modify store outside of components', () => { + useTaskStore.setState({ tasks: INITIAL_TASKS }); + expect(getAllTasks()).toEqual(INITIAL_TASKS); + + const NEW_TASK = { id: '2', title: 'Buy almond milk' }; + addTask(NEW_TASK); + expect(getAllTasks()).toEqual([...INITIAL_TASKS, NEW_TASK]); +}); diff --git a/examples/cookbook/app/state-management/zustand/__tests__/test-utils.tsx b/examples/cookbook/app/state-management/zustand/__tests__/test-utils.tsx new file mode 100644 index 000000000..4242d1dde --- /dev/null +++ b/examples/cookbook/app/state-management/zustand/__tests__/test-utils.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react-native'; +import * as React from 'react'; + +import { useTaskStore } from '../state'; +import { Task } from '../types'; + +export type TaskStoreInitialState = { + tasks?: Task[]; + newTaskTitle?: string; +}; + +export interface RenderWithStoreOptions { + initialState?: TaskStoreInitialState; +} + +/** + * Renders a React component with a Zustand store hydrated for testing. + * + * Resets the store before each render so tests stay isolated, then optionally + * applies `initialState` via `setState`. + */ +export async function renderWithStore( + component: React.ReactElement, + options: RenderWithStoreOptions = {}, +) { + useTaskStore.getState().reset(); + + if (options.initialState) { + useTaskStore.setState(options.initialState); + } + + return await render(component); +} diff --git a/examples/cookbook/app/state-management/zustand/index.tsx b/examples/cookbook/app/state-management/zustand/index.tsx new file mode 100644 index 000000000..c5dc45f24 --- /dev/null +++ b/examples/cookbook/app/state-management/zustand/index.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +import { TaskList } from './TaskList'; + +export default function Example() { + return ; +} diff --git a/examples/cookbook/app/state-management/zustand/state.ts b/examples/cookbook/app/state-management/zustand/state.ts new file mode 100644 index 000000000..8808b7611 --- /dev/null +++ b/examples/cookbook/app/state-management/zustand/state.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; + +import { Task } from './types'; + +type TaskState = { + tasks: Task[]; + newTaskTitle: string; + setNewTaskTitle: (title: string) => void; + addTask: (task: Task) => void; + reset: () => void; +}; + +const initialState = { + tasks: [] as Task[], + newTaskTitle: '', +}; + +export const useTaskStore = create((set) => ({ + ...initialState, + setNewTaskTitle: (newTaskTitle) => set({ newTaskTitle }), + addTask: (task) => + set((state) => ({ + tasks: [...state.tasks, task], + newTaskTitle: '', + })), + reset: () => set(initialState), +})); + +// Available for use outside React components +export function getAllTasks(): Task[] { + return useTaskStore.getState().tasks; +} + +export function addTask(task: Task) { + useTaskStore.getState().addTask(task); +} diff --git a/examples/cookbook/app/state-management/zustand/types.ts b/examples/cookbook/app/state-management/zustand/types.ts new file mode 100644 index 000000000..1adad20ee --- /dev/null +++ b/examples/cookbook/app/state-management/zustand/types.ts @@ -0,0 +1,4 @@ +export type Task = { + id: string; + title: string; +}; diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index f9396f38f..ea6d98064 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -27,7 +27,8 @@ "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-web": "^0.21.0", - "react-strict-dom": "^0.0.55" + "react-strict-dom": "^0.0.55", + "zustand": "^5.0.0" }, "devDependencies": { "@babel/core": "^7.29.0", diff --git a/website/docs/12.x/cookbook/state-management/_meta.json b/website/docs/12.x/cookbook/state-management/_meta.json index beac50b85..4047a6e13 100644 --- a/website/docs/12.x/cookbook/state-management/_meta.json +++ b/website/docs/12.x/cookbook/state-management/_meta.json @@ -1 +1,4 @@ -["jotai"] +[ + "jotai", + "zustand" +] diff --git a/website/docs/12.x/cookbook/state-management/zustand.md b/website/docs/12.x/cookbook/state-management/zustand.md new file mode 100644 index 000000000..14b0da725 --- /dev/null +++ b/website/docs/12.x/cookbook/state-management/zustand.md @@ -0,0 +1,221 @@ +# Zustand + +## Introduction + +Zustand is a small, unopinionated state management library for React. It exposes a hook-based API +with a vanilla store underneath, so you can read and update state both inside and outside of React +components without requiring a provider. + +## Task List Example + +Let's assume we have a simple task list component that uses Zustand for state management. The +component has a list of tasks, a text input for typing a new task name, and a button to add a new +task to the list. + +```tsx title=state-management/zustand/TaskList.tsx +import * as React from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { nanoid } from 'nanoid'; +import { useTaskStore } from './state'; + +export function TaskList() { + const tasks = useTaskStore((state) => state.tasks); + const newTaskTitle = useTaskStore((state) => state.newTaskTitle); + const setNewTaskTitle = useTaskStore((state) => state.setNewTaskTitle); + const addTask = useTaskStore((state) => state.addTask); + + const handleAddTask = () => { + addTask({ + id: nanoid(), + title: newTaskTitle, + }); + }; + + return ( + + {tasks.map((task) => ( + + {task.title} + + ))} + + {!tasks.length ? No tasks, start by adding one... : null} + + setNewTaskTitle(text)} + /> + + + Add Task + + + ); +} +``` + +## Starting with a Simple Test + +We can test our `TaskList` component using React Native Testing Library's (RNTL) regular `render` +function. Although it is sufficient to test the empty state of the `TaskList` component, shared +module-level Zustand stores keep state between tests, so we need a bit more setup for non-empty +scenarios. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +import * as React from 'react'; +import { render, screen, userEvent } from '@testing-library/react-native'; +import { renderWithStore } from './test-utils'; +import { TaskList } from '../TaskList'; +import { useTaskStore } from '../state'; +import { Task } from '../types'; + +jest.useFakeTimers(); + +afterEach(() => { + useTaskStore.getState().reset(); +}); + +test('renders an empty task list', async () => { + await render(); + expect(screen.getByText(/no tasks, start by adding one/i)).toBeOnTheScreen(); +}); +``` + +## Custom Render Function to Populate the Zustand Store + +To test the `TaskList` component with initial tasks, create a custom render helper that resets the +store and applies an optional `initialState` with `setState`. Resetting in the helper (and in +`afterEach`) keeps tests isolated despite the module-level store. + +```tsx title=state-management/zustand/__tests__/test-utils.tsx +import * as React from 'react'; +import { render } from '@testing-library/react-native'; +import { useTaskStore } from '../state'; +import { Task } from '../types'; + +export type TaskStoreInitialState = { + tasks?: Task[]; + newTaskTitle?: string; +}; + +export interface RenderWithStoreOptions { + initialState?: TaskStoreInitialState; +} + +/** + * Renders a React component with a Zustand store hydrated for testing. + * + * Resets the store before each render so tests stay isolated, then optionally + * applies `initialState` via `setState`. + */ +export async function renderWithStore( + component: React.ReactElement, + options: RenderWithStoreOptions = {}, +) { + useTaskStore.getState().reset(); + + if (options.initialState) { + useTaskStore.setState(options.initialState); + } + + return await render(component); +} +``` + +## Testing the `TaskList` Component with Initial Tasks + +We can now use the `renderWithStore` function to render the `TaskList` component with initial tasks. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread' }]; + +test('renders a to do list with 1 items initially, and adds a new item', async () => { + await renderWithStore(, { + initialState: { + tasks: INITIAL_TASKS, + newTaskTitle: '', + }, + }); + + expect(screen.getByText(/buy bread/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(1); + + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText(/new task/i), 'Buy almond milk'); + await user.press(screen.getByRole('button', { name: /add task/i })); + + expect(screen.getByText(/buy almond milk/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(2); +}); +``` + +## Modifying the Store Outside of React Components + +Zustand exposes `getState` / `setState` on the store hook, so selectors and actions can run outside +React without extra setup. + +```tsx title=state-management/zustand/state.ts +import { create } from 'zustand'; +import { Task } from './types'; + +type TaskState = { + tasks: Task[]; + newTaskTitle: string; + setNewTaskTitle: (title: string) => void; + addTask: (task: Task) => void; + reset: () => void; +}; + +const initialState = { + tasks: [] as Task[], + newTaskTitle: '', +}; + +export const useTaskStore = create((set) => ({ + ...initialState, + setNewTaskTitle: (newTaskTitle) => set({ newTaskTitle }), + addTask: (task) => + set((state) => ({ + tasks: [...state.tasks, task], + newTaskTitle: '', + })), + reset: () => set(initialState), +})); + +// Selectors / actions available outside React components +export function getAllTasks(): Task[] { + return useTaskStore.getState().tasks; +} + +export function addTask(task: Task) { + useTaskStore.getState().addTask(task); +} +``` + +## Testing the Store Outside of React Components + +You can test `getAllTasks` and `addTask` by setting initial state on the store and asserting on +`getState` results. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +import { addTask, getAllTasks, useTaskStore } from '../state'; + +//... + +test('modify store outside of components', () => { + useTaskStore.setState({ tasks: INITIAL_TASKS }); + expect(getAllTasks()).toEqual(INITIAL_TASKS); + + const NEW_TASK = { id: '2', title: 'Buy almond milk' }; + addTask(NEW_TASK); + expect(getAllTasks()).toEqual([...INITIAL_TASKS, NEW_TASK]); +}); +``` + +## Conclusion + +Testing components that use Zustand is straightforward with a small `renderWithStore` helper that +resets the module store and hydrates initial state via `setState`. The same store API works outside +React, so you can cover selectors and actions without mounting a component tree. diff --git a/website/docs/13.x/cookbook/state-management/_meta.json b/website/docs/13.x/cookbook/state-management/_meta.json index beac50b85..4047a6e13 100644 --- a/website/docs/13.x/cookbook/state-management/_meta.json +++ b/website/docs/13.x/cookbook/state-management/_meta.json @@ -1 +1,4 @@ -["jotai"] +[ + "jotai", + "zustand" +] diff --git a/website/docs/13.x/cookbook/state-management/zustand.md b/website/docs/13.x/cookbook/state-management/zustand.md new file mode 100644 index 000000000..14b0da725 --- /dev/null +++ b/website/docs/13.x/cookbook/state-management/zustand.md @@ -0,0 +1,221 @@ +# Zustand + +## Introduction + +Zustand is a small, unopinionated state management library for React. It exposes a hook-based API +with a vanilla store underneath, so you can read and update state both inside and outside of React +components without requiring a provider. + +## Task List Example + +Let's assume we have a simple task list component that uses Zustand for state management. The +component has a list of tasks, a text input for typing a new task name, and a button to add a new +task to the list. + +```tsx title=state-management/zustand/TaskList.tsx +import * as React from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { nanoid } from 'nanoid'; +import { useTaskStore } from './state'; + +export function TaskList() { + const tasks = useTaskStore((state) => state.tasks); + const newTaskTitle = useTaskStore((state) => state.newTaskTitle); + const setNewTaskTitle = useTaskStore((state) => state.setNewTaskTitle); + const addTask = useTaskStore((state) => state.addTask); + + const handleAddTask = () => { + addTask({ + id: nanoid(), + title: newTaskTitle, + }); + }; + + return ( + + {tasks.map((task) => ( + + {task.title} + + ))} + + {!tasks.length ? No tasks, start by adding one... : null} + + setNewTaskTitle(text)} + /> + + + Add Task + + + ); +} +``` + +## Starting with a Simple Test + +We can test our `TaskList` component using React Native Testing Library's (RNTL) regular `render` +function. Although it is sufficient to test the empty state of the `TaskList` component, shared +module-level Zustand stores keep state between tests, so we need a bit more setup for non-empty +scenarios. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +import * as React from 'react'; +import { render, screen, userEvent } from '@testing-library/react-native'; +import { renderWithStore } from './test-utils'; +import { TaskList } from '../TaskList'; +import { useTaskStore } from '../state'; +import { Task } from '../types'; + +jest.useFakeTimers(); + +afterEach(() => { + useTaskStore.getState().reset(); +}); + +test('renders an empty task list', async () => { + await render(); + expect(screen.getByText(/no tasks, start by adding one/i)).toBeOnTheScreen(); +}); +``` + +## Custom Render Function to Populate the Zustand Store + +To test the `TaskList` component with initial tasks, create a custom render helper that resets the +store and applies an optional `initialState` with `setState`. Resetting in the helper (and in +`afterEach`) keeps tests isolated despite the module-level store. + +```tsx title=state-management/zustand/__tests__/test-utils.tsx +import * as React from 'react'; +import { render } from '@testing-library/react-native'; +import { useTaskStore } from '../state'; +import { Task } from '../types'; + +export type TaskStoreInitialState = { + tasks?: Task[]; + newTaskTitle?: string; +}; + +export interface RenderWithStoreOptions { + initialState?: TaskStoreInitialState; +} + +/** + * Renders a React component with a Zustand store hydrated for testing. + * + * Resets the store before each render so tests stay isolated, then optionally + * applies `initialState` via `setState`. + */ +export async function renderWithStore( + component: React.ReactElement, + options: RenderWithStoreOptions = {}, +) { + useTaskStore.getState().reset(); + + if (options.initialState) { + useTaskStore.setState(options.initialState); + } + + return await render(component); +} +``` + +## Testing the `TaskList` Component with Initial Tasks + +We can now use the `renderWithStore` function to render the `TaskList` component with initial tasks. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread' }]; + +test('renders a to do list with 1 items initially, and adds a new item', async () => { + await renderWithStore(, { + initialState: { + tasks: INITIAL_TASKS, + newTaskTitle: '', + }, + }); + + expect(screen.getByText(/buy bread/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(1); + + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText(/new task/i), 'Buy almond milk'); + await user.press(screen.getByRole('button', { name: /add task/i })); + + expect(screen.getByText(/buy almond milk/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(2); +}); +``` + +## Modifying the Store Outside of React Components + +Zustand exposes `getState` / `setState` on the store hook, so selectors and actions can run outside +React without extra setup. + +```tsx title=state-management/zustand/state.ts +import { create } from 'zustand'; +import { Task } from './types'; + +type TaskState = { + tasks: Task[]; + newTaskTitle: string; + setNewTaskTitle: (title: string) => void; + addTask: (task: Task) => void; + reset: () => void; +}; + +const initialState = { + tasks: [] as Task[], + newTaskTitle: '', +}; + +export const useTaskStore = create((set) => ({ + ...initialState, + setNewTaskTitle: (newTaskTitle) => set({ newTaskTitle }), + addTask: (task) => + set((state) => ({ + tasks: [...state.tasks, task], + newTaskTitle: '', + })), + reset: () => set(initialState), +})); + +// Selectors / actions available outside React components +export function getAllTasks(): Task[] { + return useTaskStore.getState().tasks; +} + +export function addTask(task: Task) { + useTaskStore.getState().addTask(task); +} +``` + +## Testing the Store Outside of React Components + +You can test `getAllTasks` and `addTask` by setting initial state on the store and asserting on +`getState` results. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +import { addTask, getAllTasks, useTaskStore } from '../state'; + +//... + +test('modify store outside of components', () => { + useTaskStore.setState({ tasks: INITIAL_TASKS }); + expect(getAllTasks()).toEqual(INITIAL_TASKS); + + const NEW_TASK = { id: '2', title: 'Buy almond milk' }; + addTask(NEW_TASK); + expect(getAllTasks()).toEqual([...INITIAL_TASKS, NEW_TASK]); +}); +``` + +## Conclusion + +Testing components that use Zustand is straightforward with a small `renderWithStore` helper that +resets the module store and hydrates initial state via `setState`. The same store API works outside +React, so you can cover selectors and actions without mounting a component tree. diff --git a/website/docs/14.x/cookbook/state-management/_meta.json b/website/docs/14.x/cookbook/state-management/_meta.json index beac50b85..4047a6e13 100644 --- a/website/docs/14.x/cookbook/state-management/_meta.json +++ b/website/docs/14.x/cookbook/state-management/_meta.json @@ -1 +1,4 @@ -["jotai"] +[ + "jotai", + "zustand" +] diff --git a/website/docs/14.x/cookbook/state-management/zustand.md b/website/docs/14.x/cookbook/state-management/zustand.md new file mode 100644 index 000000000..14b0da725 --- /dev/null +++ b/website/docs/14.x/cookbook/state-management/zustand.md @@ -0,0 +1,221 @@ +# Zustand + +## Introduction + +Zustand is a small, unopinionated state management library for React. It exposes a hook-based API +with a vanilla store underneath, so you can read and update state both inside and outside of React +components without requiring a provider. + +## Task List Example + +Let's assume we have a simple task list component that uses Zustand for state management. The +component has a list of tasks, a text input for typing a new task name, and a button to add a new +task to the list. + +```tsx title=state-management/zustand/TaskList.tsx +import * as React from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { nanoid } from 'nanoid'; +import { useTaskStore } from './state'; + +export function TaskList() { + const tasks = useTaskStore((state) => state.tasks); + const newTaskTitle = useTaskStore((state) => state.newTaskTitle); + const setNewTaskTitle = useTaskStore((state) => state.setNewTaskTitle); + const addTask = useTaskStore((state) => state.addTask); + + const handleAddTask = () => { + addTask({ + id: nanoid(), + title: newTaskTitle, + }); + }; + + return ( + + {tasks.map((task) => ( + + {task.title} + + ))} + + {!tasks.length ? No tasks, start by adding one... : null} + + setNewTaskTitle(text)} + /> + + + Add Task + + + ); +} +``` + +## Starting with a Simple Test + +We can test our `TaskList` component using React Native Testing Library's (RNTL) regular `render` +function. Although it is sufficient to test the empty state of the `TaskList` component, shared +module-level Zustand stores keep state between tests, so we need a bit more setup for non-empty +scenarios. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +import * as React from 'react'; +import { render, screen, userEvent } from '@testing-library/react-native'; +import { renderWithStore } from './test-utils'; +import { TaskList } from '../TaskList'; +import { useTaskStore } from '../state'; +import { Task } from '../types'; + +jest.useFakeTimers(); + +afterEach(() => { + useTaskStore.getState().reset(); +}); + +test('renders an empty task list', async () => { + await render(); + expect(screen.getByText(/no tasks, start by adding one/i)).toBeOnTheScreen(); +}); +``` + +## Custom Render Function to Populate the Zustand Store + +To test the `TaskList` component with initial tasks, create a custom render helper that resets the +store and applies an optional `initialState` with `setState`. Resetting in the helper (and in +`afterEach`) keeps tests isolated despite the module-level store. + +```tsx title=state-management/zustand/__tests__/test-utils.tsx +import * as React from 'react'; +import { render } from '@testing-library/react-native'; +import { useTaskStore } from '../state'; +import { Task } from '../types'; + +export type TaskStoreInitialState = { + tasks?: Task[]; + newTaskTitle?: string; +}; + +export interface RenderWithStoreOptions { + initialState?: TaskStoreInitialState; +} + +/** + * Renders a React component with a Zustand store hydrated for testing. + * + * Resets the store before each render so tests stay isolated, then optionally + * applies `initialState` via `setState`. + */ +export async function renderWithStore( + component: React.ReactElement, + options: RenderWithStoreOptions = {}, +) { + useTaskStore.getState().reset(); + + if (options.initialState) { + useTaskStore.setState(options.initialState); + } + + return await render(component); +} +``` + +## Testing the `TaskList` Component with Initial Tasks + +We can now use the `renderWithStore` function to render the `TaskList` component with initial tasks. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread' }]; + +test('renders a to do list with 1 items initially, and adds a new item', async () => { + await renderWithStore(, { + initialState: { + tasks: INITIAL_TASKS, + newTaskTitle: '', + }, + }); + + expect(screen.getByText(/buy bread/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(1); + + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText(/new task/i), 'Buy almond milk'); + await user.press(screen.getByRole('button', { name: /add task/i })); + + expect(screen.getByText(/buy almond milk/i)).toBeOnTheScreen(); + expect(screen.getAllByTestId('task-item')).toHaveLength(2); +}); +``` + +## Modifying the Store Outside of React Components + +Zustand exposes `getState` / `setState` on the store hook, so selectors and actions can run outside +React without extra setup. + +```tsx title=state-management/zustand/state.ts +import { create } from 'zustand'; +import { Task } from './types'; + +type TaskState = { + tasks: Task[]; + newTaskTitle: string; + setNewTaskTitle: (title: string) => void; + addTask: (task: Task) => void; + reset: () => void; +}; + +const initialState = { + tasks: [] as Task[], + newTaskTitle: '', +}; + +export const useTaskStore = create((set) => ({ + ...initialState, + setNewTaskTitle: (newTaskTitle) => set({ newTaskTitle }), + addTask: (task) => + set((state) => ({ + tasks: [...state.tasks, task], + newTaskTitle: '', + })), + reset: () => set(initialState), +})); + +// Selectors / actions available outside React components +export function getAllTasks(): Task[] { + return useTaskStore.getState().tasks; +} + +export function addTask(task: Task) { + useTaskStore.getState().addTask(task); +} +``` + +## Testing the Store Outside of React Components + +You can test `getAllTasks` and `addTask` by setting initial state on the store and asserting on +`getState` results. + +```tsx title=state-management/zustand/__tests__/TaskList.test.tsx +import { addTask, getAllTasks, useTaskStore } from '../state'; + +//... + +test('modify store outside of components', () => { + useTaskStore.setState({ tasks: INITIAL_TASKS }); + expect(getAllTasks()).toEqual(INITIAL_TASKS); + + const NEW_TASK = { id: '2', title: 'Buy almond milk' }; + addTask(NEW_TASK); + expect(getAllTasks()).toEqual([...INITIAL_TASKS, NEW_TASK]); +}); +``` + +## Conclusion + +Testing components that use Zustand is straightforward with a small `renderWithStore` helper that +resets the module store and hydrates initial state via `setState`. The same store API works outside +React, so you can cover selectors and actions without mounting a component tree. From c5e442e1b7ced029fc63f8c98ee22c6f57ad4e6f Mon Sep 17 00:00:00 2001 From: Daniel Chen <305872926+daniel-chen-j@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:09:16 +0100 Subject: [PATCH 2/2] chore: update cookbook yarn.lock for zustand --- examples/cookbook/yarn.lock | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/cookbook/yarn.lock b/examples/cookbook/yarn.lock index 39c59ece5..b63aa654c 100644 --- a/examples/cookbook/yarn.lock +++ b/examples/cookbook/yarn.lock @@ -7880,6 +7880,7 @@ __metadata: test-renderer: "npm:1.2.0" typescript: "npm:~6.0.3" typescript-eslint: "npm:^8.58.1" + zustand: "npm:^5.0.0" languageName: unknown linkType: soft @@ -9008,3 +9009,24 @@ __metadata: checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c languageName: node linkType: hard + +"zustand@npm:^5.0.0": + version: 5.0.14 + resolution: "zustand@npm:5.0.14" + peerDependencies: + "@types/react": ">=18.0.0" + immer: ">=9.0.6" + react: ">=18.0.0" + use-sync-external-store: ">=1.2.0" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + checksum: 10c0/89de0c8119f1e0861b2f896a0b4df0d67994a373ecee02628d4e7b97cee1885207f646917d3ede395c3a5083316d48846855cfbc84aced7cd23a6db3238930a8 + languageName: node + linkType: hard