Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/cookbook/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
},
Expand Down
52 changes: 52 additions & 0 deletions examples/cookbook/app/state-management/zustand/TaskList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
{tasks.map((task) => (
<Text key={task.id} testID="task-item">
{task.title}
</Text>
))}

{!tasks.length ? <Text>No tasks, start by adding one...</Text> : null}

<TextInput
aria-label="New Task"
placeholder="New Task..."
value={newTaskTitle}
onChangeText={(text) => setNewTaskTitle(text)}
/>

<Pressable role="button" onPress={handleAddTask}>
<Text>Add Task</Text>
</Pressable>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
Original file line number Diff line number Diff line change
@@ -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(<TaskList />);
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(<TaskList />, {
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]);
});
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 7 additions & 0 deletions examples/cookbook/app/state-management/zustand/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

import { TaskList } from './TaskList';

export default function Example() {
return <TaskList />;
}
36 changes: 36 additions & 0 deletions examples/cookbook/app/state-management/zustand/state.ts
Original file line number Diff line number Diff line change
@@ -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<TaskState>((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);
}
4 changes: 4 additions & 0 deletions examples/cookbook/app/state-management/zustand/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Task = {
id: string;
title: string;
};
3 changes: 2 additions & 1 deletion examples/cookbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions examples/cookbook/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion website/docs/12.x/cookbook/state-management/_meta.json
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
["jotai"]
[
"jotai",
"zustand"
]
Loading