Skip to main content

Tutorial with createContainer - ToDo App with useState

This tutorial shows example code with useState, Immer and custom hooks.

src/components/App.js

import React from 'react';

import { Provider } from '../store';
import TodoList from './TodoList';

const App = () => (
<Provider>
<TodoList />
</Provider>
);

export default App;

This is the root component. It wraps TodoList with Provider.

src/store.js

import { useState, useCallback } from 'react';
import { createContainer } from 'react-tracked';
import produce from 'immer';

const initialState = {
todos: [
{ id: 1, title: 'Wash dishes' },
{ id: 2, title: 'Study JS' },
{ id: 3, title: 'Buy ticket' },
],
query: '',
};

const useValue = () => useState(initialState);

const {
Provider,
useTrackedState,
useUpdate: useSetState,
} = createContainer(useValue);

const useSetDraft = () => {
const setState = useSetState();
return useCallback(
(draftUpdater) => {
setState(produce(draftUpdater));
},
[setState],
);
};

export { Provider, useTrackedState, useSetDraft };

The store is created by useState. useUpdate is renamed to useSetState, and based on it, useSetDraft with Immer is exported.

src/hooks/useTodoList.js

import { useTrackedState } from '../store';

export const useTodoList = () => {
const state = useTrackedState();
return state.todos;
};

This is a custom hook to simply return todos.

src/hooks/useAddTodo.js

import { useCallback } from 'react';

import { useSetDraft } from '../store';

let nextId = 100;

export const useAddTodo = () => {
const setDraft = useSetDraft();
return useCallback(
(title) => {
setDraft((draft) => {
draft.todos.push({ id: nextId++, title });
});
},
[setDraft],
);
};

This is a custom hook to return addTodo function.

src/hooks/useDeleteTodo.js

import { useCallback } from 'react';

import { useSetDraft } from '../store';

export const useDeleteTodo = () => {
const setDraft = useSetDraft();
return useCallback(
(id) => {
setDraft((draft) => {
const index = draft.todos.findIndex((todo) => todo.id === id);
if (index >= 0) draft.todos.splice(index, 1);
});
},
[setDraft],
);
};

This is a custom hook to return deleteTodo function.

src/hooks/useToogleTodo.js

import { useCallback } from 'react';

import { useSetDraft } from '../store';

export const useToggleTodo = () => {
const setDraft = useSetDraft();
return useCallback(
(id) => {
setDraft((draft) => {
const todo = draft.todos.find((todo) => todo.id === id);
if (todo) todo.completed = !todo.completed;
});
},
[setDraft],
);
};

This is a custom hook to return toggleTodo function.

src/hooks/useQuery.js

import { useCallback } from 'react';

import { useTrackedState, useSetDraft } from '../store';

export const useQuery = () => {
const state = useTrackedState();
const getQuery = () => state.query;
const setDraft = useSetDraft();
const setQuery = useCallback(
(query) => {
setDraft((draft) => {
draft.query = query;
});
},
[setDraft],
);
return { getQuery, setQuery };
};

This is a custom hook to return getQuery and setQuery. It doesn't return state.query directly, because it will be used conditionally.

src/components/TodoList.js

import React from 'react';

import { useTodoList } from '../hooks/useTodoList';
import { useQuery } from '../hooks/useQuery';
import TodoItem from './TodoItem';
import NewTodo from './NewTodo';

const TodoList = () => {
const { getQuery, setQuery } = useQuery();
const todos = useTodoList();
return (
<div>
<ul>
{todos.map(({ id, title, completed }) => (
<TodoItem key={id} id={id} title={title} completed={completed} />
))}
<NewTodo />
</ul>
<div>
Highlight Query for incomplete items:
<input value={getQuery()} onChange={(e) => setQuery(e.target.value)} />
</div>
</div>
);
};

export default TodoList;

This component is to show the list of TodoItems, NewTodo to create a new item, and Clear button to reset notes in all items.

src/components/TodoItem.js

import React from 'react';

import { useQuery } from '../hooks/useQuery';
import { useDeleteTodo } from '../hooks/useDeleteTodo';
import { useToggleTodo } from '../hooks/useToggleTodo';
import { useFlasher } from '../utils';

const renderHighlight = (title, query) => {
if (!query) return title;
const index = title.indexOf(query);
if (index === -1) return title;
return (
<>
{title.slice(0, index)}
<b>{query}</b>
{title.slice(index + query.length)}
</>
);
};

const TodoItem = ({ id, title, completed }) => {
const { getQuery } = useQuery();
const deleteTodo = useDeleteTodo();
const toggleTodo = useToggleTodo();
return (
<li ref={useFlasher()}>
<input
type="checkbox"
checked={!!completed}
onChange={() => toggleTodo(id)}
/>
<span
style={{
textDecoration: completed ? 'line-through' : 'none',
}}
>
{completed ? title : renderHighlight(title, getQuery())}
</span>
<button onClick={() => deleteTodo(id)}>Delete</button>
</li>
);
};

export default React.memo(TodoItem);

This is the TodoItem component. We used to prefer primitive props for memoized components with v1. With v2, object props are also fine.

src/components/NewTodo.js

import React, { useState } from 'react';

import { useAddTodo } from '../hooks/useAddTodo';
import { useFlasher } from '../utils';

const NewTodo = () => {
const addTodo = useAddTodo();
const [text, setText] = useState('');
return (
<li ref={useFlasher()}>
<input
value={text}
placeholder="Enter title..."
onChange={(e) => setText(e.target.value)}
/>
<button
onClick={() => {
addTodo(text);
setText('');
}}
>
Add
</button>
</li>
);
};

export default React.memo(NewTodo);

This is the NewTodo component to create a new item. It uses a local state for the text field.

src/utils.js

import { useRef, useEffect } from 'react';

export const useFlasher = () => {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
ref.current.setAttribute(
'style',
'box-shadow: 0 0 2px 1px red; transition: box-shadow 100ms ease-out;',
);
}
const timeOutId = setTimeout(() => {
if (ref.current) {
ref.current.setAttribute('style', '');
}
}, 300);
return () => {
clearTimeout(timeOutId);
};
}, []);
return ref;
};

This is a util function to show which components render.

CodeSandbox

You can try working example.