From 31a1d17ef1148b3bf9898530c69f6ac4b3c1d332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 16 Jul 2024 10:51:49 +0200 Subject: [PATCH 1/2] chore: Zustand wip --- .../cookbook/app/state/zustand/TaskList.tsx | 49 +++++++++++++++++++ .../state/zustand/__tests__/TaskList.test.tsx | 42 ++++++++++++++++ examples/cookbook/app/state/zustand/state.ts | 12 +++++ .../cookbook/app/state/zustand/test-utils.tsx | 20 ++++++++ examples/cookbook/app/state/zustand/types.ts | 4 ++ examples/cookbook/app/utils.ts | 6 +++ examples/cookbook/package.json | 3 +- examples/cookbook/yarn.lock | 30 ++++++++++++ 8 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 examples/cookbook/app/state/zustand/TaskList.tsx create mode 100644 examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx create mode 100644 examples/cookbook/app/state/zustand/state.ts create mode 100644 examples/cookbook/app/state/zustand/test-utils.tsx create mode 100644 examples/cookbook/app/state/zustand/types.ts create mode 100644 examples/cookbook/app/utils.ts diff --git a/examples/cookbook/app/state/zustand/TaskList.tsx b/examples/cookbook/app/state/zustand/TaskList.tsx new file mode 100644 index 000000000..33e7e6382 --- /dev/null +++ b/examples/cookbook/app/state/zustand/TaskList.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { generateId } from '../../utils'; +import { useTasksStore } from './state'; + +export default function TaskList() { + const tasks = useTasksStore((state) => state.tasks); + const addTask = useTasksStore((state) => state.addTask); + const [newTaskTitle, setNewTaskTitle] = React.useState(''); + + const handleAddTask = () => { + addTask({ + id: generateId(), + title: newTaskTitle, + }); + setNewTaskTitle(''); + }; + + return ( + + {tasks.map((task) => ( + + {task.title} + + ))} + + {!tasks.length ? No tasks, start by adding one... : null} + + setNewTaskTitle(text)} + /> + + + Add Task + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx b/examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx new file mode 100644 index 000000000..16d008049 --- /dev/null +++ b/examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { render, screen, userEvent } from '@testing-library/react-native'; +import TaskList from '../TaskList'; +import { addTask, getAllTasks, newTaskTitleAtom, store, tasksAtom } from '../state'; + +jest.useFakeTimers(); + +test('renders an empty task list', () => { + 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 () => { + renderWithAtoms(, { + initialValues: [ + [tasksAtom, INITIAL_TASKS], + [newTaskTitleAtom, ''], + ], + }); + + 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', () => { + // Set the initial to do items in the store + store.set(tasksAtom, 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/zustand/state.ts b/examples/cookbook/app/state/zustand/state.ts new file mode 100644 index 000000000..27dd99d9a --- /dev/null +++ b/examples/cookbook/app/state/zustand/state.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; +import { Task } from './types'; + +export interface TasksState { + tasks: Task[]; + addTask: (task: Task) => void; +} + +export const useTasksStore = create((set) => ({ + tasks: [], + addTask: (task: Task) => set((state) => ({ tasks: [...state.tasks, task] })), +})); diff --git a/examples/cookbook/app/state/zustand/test-utils.tsx b/examples/cookbook/app/state/zustand/test-utils.tsx new file mode 100644 index 000000000..2e72658c8 --- /dev/null +++ b/examples/cookbook/app/state/zustand/test-utils.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { render } from '@testing-library/react-native'; +import { TasksState } from './state'; + +export interface RenderWithState { + initialState: Partial; +} + +/** + * Renders a React component with Jotai atoms for testing purposes. + * + * @param component - The React component to render. + * @param options - The render options including the initial atom values. + * @returns The render result from `@testing-library/react-native`. + */ +export const renderWithState = (component: React.ReactElement, options: RenderWithState) => { + return render( + {component}, + ); +}; diff --git a/examples/cookbook/app/state/zustand/types.ts b/examples/cookbook/app/state/zustand/types.ts new file mode 100644 index 000000000..1adad20ee --- /dev/null +++ b/examples/cookbook/app/state/zustand/types.ts @@ -0,0 +1,4 @@ +export type Task = { + id: string; + title: string; +}; diff --git a/examples/cookbook/app/utils.ts b/examples/cookbook/app/utils.ts new file mode 100644 index 000000000..4ed3aff17 --- /dev/null +++ b/examples/cookbook/app/utils.ts @@ -0,0 +1,6 @@ +import 'react-native-get-random-values'; +import { nanoid } from 'nanoid'; + +export function generateId() { + return nanoid(); +} diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 14502e7af..bf164d0ca 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -25,7 +25,8 @@ "react-native-get-random-values": "~1.8.0", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", - "react-native-web": "~0.19.6" + "react-native-web": "~0.19.6", + "zustand": "^4.5.4" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/examples/cookbook/yarn.lock b/examples/cookbook/yarn.lock index b2d45010c..31dc35e9b 100644 --- a/examples/cookbook/yarn.lock +++ b/examples/cookbook/yarn.lock @@ -10332,6 +10332,7 @@ __metadata: react-native-web: "npm:~0.19.6" react-test-renderer: "npm:18.2.0" typescript: "npm:^5.3.0" + zustand: "npm:^4.5.4" languageName: unknown linkType: soft @@ -11680,6 +11681,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:1.2.0": + version: 1.2.0 + resolution: "use-sync-external-store@npm:1.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/ac4814e5592524f242921157e791b022efe36e451fe0d4fd4d204322d5433a4fc300d63b0ade5185f8e0735ded044c70bcf6d2352db0f74d097a238cebd2da02 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -12182,3 +12192,23 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"zustand@npm:^4.5.4": + version: 4.5.4 + resolution: "zustand@npm:4.5.4" + dependencies: + use-sync-external-store: "npm:1.2.0" + peerDependencies: + "@types/react": ">=16.8" + immer: ">=9.0.6" + react: ">=16.8" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + checksum: 10c0/479af491ffa1f1eb2c38b3ba25dc4e14339e8b35a60033d3f6c165b22f8be8163f7e1370015ded9c6e28548cd25af84a73fb40b5fad0bd7882d16ddd5ed613c6 + languageName: node + linkType: hard From 19c4db81ef123f4ac7276aa750c965bf6ded7775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 16 Jul 2024 12:52:35 +0200 Subject: [PATCH 2/2] feat: add more opps --- .../cookbook/app/state/zustand/TaskList.tsx | 2 + .../state/zustand/__tests__/TaskList.test.tsx | 28 +++----- examples/cookbook/app/state/zustand/index.tsx | 14 ++++ examples/cookbook/app/state/zustand/state.ts | 12 ---- examples/cookbook/app/state/zustand/state.tsx | 67 +++++++++++++++++++ .../cookbook/app/state/zustand/test-utils.tsx | 20 +++--- examples/cookbook/app/state/zustand/types.ts | 1 + 7 files changed, 105 insertions(+), 39 deletions(-) create mode 100644 examples/cookbook/app/state/zustand/index.tsx delete mode 100644 examples/cookbook/app/state/zustand/state.ts create mode 100644 examples/cookbook/app/state/zustand/state.tsx diff --git a/examples/cookbook/app/state/zustand/TaskList.tsx b/examples/cookbook/app/state/zustand/TaskList.tsx index 33e7e6382..c947cd3b5 100644 --- a/examples/cookbook/app/state/zustand/TaskList.tsx +++ b/examples/cookbook/app/state/zustand/TaskList.tsx @@ -6,12 +6,14 @@ import { useTasksStore } from './state'; export default function TaskList() { const tasks = useTasksStore((state) => state.tasks); const addTask = useTasksStore((state) => state.addTask); + const [newTaskTitle, setNewTaskTitle] = React.useState(''); const handleAddTask = () => { addTask({ id: generateId(), title: newTaskTitle, + completed: false, }); setNewTaskTitle(''); }; diff --git a/examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx b/examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx index 16d008049..8230257eb 100644 --- a/examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx +++ b/examples/cookbook/app/state/zustand/__tests__/TaskList.test.tsx @@ -1,23 +1,23 @@ import * as React from 'react'; -import { render, screen, userEvent } from '@testing-library/react-native'; +import { screen, userEvent } from '@testing-library/react-native'; import TaskList from '../TaskList'; -import { addTask, getAllTasks, newTaskTitleAtom, store, tasksAtom } from '../state'; +import { renderWithState } from '../test-utils'; +import { Task } from '../types'; jest.useFakeTimers(); test('renders an empty task list', () => { - render(); + renderWithState(); expect(screen.getByText(/no tasks, start by adding one/i)).toBeOnTheScreen(); }); -const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread' }]; +const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread', completed: false }]; test('renders a to do list with 1 items initially, and adds a new item', async () => { - renderWithAtoms(, { - initialValues: [ - [tasksAtom, INITIAL_TASKS], - [newTaskTitleAtom, ''], - ], + renderWithState(, { + initialState: { + tasks: INITIAL_TASKS, + }, }); expect(screen.getByText(/buy bread/i)).toBeOnTheScreen(); @@ -30,13 +30,3 @@ test('renders a to do list with 1 items initially, and adds a new item', async ( expect(screen.getByText(/buy almond milk/i)).toBeOnTheScreen(); expect(screen.getAllByTestId('task-item')).toHaveLength(2); }); - -test('modify store outside of components', () => { - // Set the initial to do items in the store - store.set(tasksAtom, 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/zustand/index.tsx b/examples/cookbook/app/state/zustand/index.tsx new file mode 100644 index 000000000..d8aace0b1 --- /dev/null +++ b/examples/cookbook/app/state/zustand/index.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { createStore } from 'zustand'; +import { tasksStoreCreator, TasksStoreProvider } from './state'; +import TaskList from './TaskList'; + +const store = createStore(tasksStoreCreator); + +export default function App() { + return ( + + + + ); +} diff --git a/examples/cookbook/app/state/zustand/state.ts b/examples/cookbook/app/state/zustand/state.ts deleted file mode 100644 index 27dd99d9a..000000000 --- a/examples/cookbook/app/state/zustand/state.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { create } from 'zustand'; -import { Task } from './types'; - -export interface TasksState { - tasks: Task[]; - addTask: (task: Task) => void; -} - -export const useTasksStore = create((set) => ({ - tasks: [], - addTask: (task: Task) => set((state) => ({ tasks: [...state.tasks, task] })), -})); diff --git a/examples/cookbook/app/state/zustand/state.tsx b/examples/cookbook/app/state/zustand/state.tsx new file mode 100644 index 000000000..1bb2d488f --- /dev/null +++ b/examples/cookbook/app/state/zustand/state.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { createContext, useContext, useRef } from 'react'; +import { createStore, StateCreator, StoreApi, useStore } from 'zustand'; +import { Task } from './types'; + +export interface TasksState { + tasks: Task[]; + addTask: (task: Task) => void; + deleteTask: (id: string) => void; + toggleTask: (id: string) => void; + clearCompleted: () => void; +} + +export const tasksStoreCreator: StateCreator = (set) => ({ + tasks: [], + addTask: (task: Task) => { + set((state) => ({ tasks: [...state.tasks, task] })); + }, + deleteTask: (id: string) => { + set((state) => ({ tasks: state.tasks.filter((todo) => todo.id !== id) })); + }, + toggleTask: (id: string) => { + set((state) => ({ + tasks: state.tasks.map((task) => + task.id === id ? { ...task, completed: !task.completed } : task, + ), + })); + }, + clearCompleted: () => { + set((state) => ({ tasks: state.tasks.filter((task) => !task.completed) })); + }, +}); + +// Use store with context +// See: https://docs.pmnd.rs/zustand/guides/testing#testing-components + +export type TasksStoreApi = StoreApi; + +const TasksStoreContext = createContext(undefined); + +export interface TasksStoreProviderProps extends React.PropsWithChildren { + // Optionally pass a pre-created store (for testing) + store?: TasksStoreApi; +} + +export function TasksStoreProvider({ store, children }: TasksStoreProviderProps) { + const storeRef = useRef(); + if (!storeRef.current) { + // Inject passed store or create a new one + storeRef.current = store ?? createStore(tasksStoreCreator); + } + + return ( + {children} + ); +} + +export type UseTasksStoreContextSelector = (store: TasksState) => T; + +export function useTasksStore(selector: UseTasksStoreContextSelector) { + const store = useContext(TasksStoreContext); + if (!store) { + throw new Error('useTasksStore must be used within TasksStoreProvider'); + } + + return useStore(store, selector); +} diff --git a/examples/cookbook/app/state/zustand/test-utils.tsx b/examples/cookbook/app/state/zustand/test-utils.tsx index 2e72658c8..56a30b7ea 100644 --- a/examples/cookbook/app/state/zustand/test-utils.tsx +++ b/examples/cookbook/app/state/zustand/test-utils.tsx @@ -1,20 +1,24 @@ import * as React from 'react'; import { render } from '@testing-library/react-native'; -import { TasksState } from './state'; +import { createStore } from 'zustand'; +import { TasksState, tasksStoreCreator, TasksStoreProvider } from './state'; export interface RenderWithState { - initialState: Partial; + initialState?: Partial; } /** - * Renders a React component with Jotai atoms for testing purposes. + * Renders a React component with Zustand state for testing purposes. * * @param component - The React component to render. - * @param options - The render options including the initial atom values. + * @param options - The render options including the initial state. * @returns The render result from `@testing-library/react-native`. */ -export const renderWithState = (component: React.ReactElement, options: RenderWithState) => { - return render( - {component}, - ); +export const renderWithState = (component: React.ReactElement, options?: RenderWithState) => { + const store = createStore(tasksStoreCreator); + if (options?.initialState) { + store.setState(options.initialState); + } + + return render({component}); }; diff --git a/examples/cookbook/app/state/zustand/types.ts b/examples/cookbook/app/state/zustand/types.ts index 1adad20ee..3c6ad6226 100644 --- a/examples/cookbook/app/state/zustand/types.ts +++ b/examples/cookbook/app/state/zustand/types.ts @@ -1,4 +1,5 @@ export type Task = { id: string; title: string; + completed: boolean; };