Skip to content

Commit

Permalink
docs: add jotai to cookbook (#1634)
Browse files Browse the repository at this point in the history
* create a Jotai examples dir in cookbook with related utils, add state management recipes dir

* add docs with examples

* extract state from component to state, utils, simplify types and custom render func.

* refactor: tweaks & cleanup

---------

Co-authored-by: stevegalili <[email protected]>
Co-authored-by: Maciej Jastrzębski <[email protected]>
  • Loading branch information
3 people authored Jul 12, 2024
1 parent 01d319c commit d31c05a
Show file tree
Hide file tree
Showing 10 changed files with 2,507 additions and 1,262 deletions.
44 changes: 44 additions & 0 deletions examples/cookbook/jotai/TaskList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as React from 'react';
import { render, screen, userEvent } from '@testing-library/react-native';
import { renderWithAtoms } from './test-utils';
import { TaskList } from './TaskList';
import { addTask, getAllTasks, newTaskTitleAtom, store, tasksAtom } from './state';
import { Task } from './types';

jest.useFakeTimers();

test('renders an empty task list', () => {
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 () => {
renderWithAtoms(<TaskList />, {
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]);
});
44 changes: 44 additions & 0 deletions examples/cookbook/jotai/TaskList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { useAtom } from 'jotai';
import { nanoid } from 'nanoid';
import { newTaskTitleAtom, tasksAtom } from './state';

export function TaskList() {
const [tasks, setTasks] = useAtom(tasksAtom);
const [newTaskTitle, setNewTaskTitle] = useAtom(newTaskTitleAtom);

const handleAddTask = () => {
setTasks((tasks) => [
...tasks,
{
id: nanoid(),
title: newTaskTitle,
},
]);
setNewTaskTitle('');
};

return (
<View>
{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
accessibilityLabel="New Task"
placeholder="New Task..."
value={newTaskTitle}
onChangeText={(text) => setNewTaskTitle(text)}
/>

<Pressable accessibilityRole="button" onPress={handleAddTask}>
<Text>Add Task</Text>
</Pressable>
</View>
);
}
18 changes: 18 additions & 0 deletions examples/cookbook/jotai/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { atom, createStore } from 'jotai';
import { Task } from './types';

export const tasksAtom = atom<Task[]>([]);
export const newTaskTitleAtom = atom('');

// Available for use outside React components
export const store = createStore();

// Selectors
export function getAllTasks(): Task[] {
return store.get(tasksAtom);
}

// Actions
export function addTask(task: Task) {
store.set(tasksAtom, [...getAllTasks(), task]);
}
44 changes: 44 additions & 0 deletions examples/cookbook/jotai/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as React from 'react';
import { render } from '@testing-library/react-native';
import { useHydrateAtoms } from 'jotai/utils';
import { PrimitiveAtom } from 'jotai/vanilla/atom';

// Jotai types are not well exported, so we will make our life easier by using `any`.
export type AtomInitialValueTuple<T> = [PrimitiveAtom<T>, T];

export interface RenderWithAtomsOptions {
initialValues: AtomInitialValueTuple<any>[];
}

/**
* 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 renderWithAtoms = <T,>(
component: React.ReactElement,
options: RenderWithAtomsOptions,
) => {
return render(
<HydrateAtomsWrapper initialValues={options.initialValues}>{component}</HydrateAtomsWrapper>,
);
};

export type HydrateAtomsWrapperProps = React.PropsWithChildren<{
initialValues: AtomInitialValueTuple<unknown>[];
}>;

/**
* A wrapper component that hydrates Jotai atoms with initial values.
*
* @param initialValues - The initial values for the Jotai atoms.
* @param children - The child components to render.
* @returns The rendered children.
*/
function HydrateAtomsWrapper({ initialValues, children }: HydrateAtomsWrapperProps) {
useHydrateAtoms(initialValues);
return children;
}
4 changes: 4 additions & 0 deletions examples/cookbook/jotai/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Task = {
id: string;
title: string;
};
2 changes: 2 additions & 0 deletions examples/cookbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"dependencies": {
"expo": "^50.0.4",
"expo-status-bar": "~1.11.1",
"jotai": "^2.8.4",
"nanoid": "^3.3.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.73.2",
Expand Down
Loading

0 comments on commit d31c05a

Please sign in to comment.