Tutorial - ToDo App with Async Actions

This tutorial shows example code with async actions.

src/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 { useReducerAsync } from 'use-reducer-async';
import { createContainer } from 'react-tracked';
const initialState = {
todoIds: [],
todoMap: {},
query: '',
pending: false,
error: null,
};
const reducer = (state, action) => {
switch (action.type) {
case 'STARTED':
return {
...state,
pending: true,
};
case 'TODO_CREATED':
return {
...state,
todoIds: [...state.todoIds, action.todo.id],
todoMap: { ...state.todoMap, [action.todo.id]: action.todo },
pending: false,
};
case 'TODO_UPDATED':
return {
...state,
todoMap: { ...state.todoMap, [action.todo.id]: action.todo },
pending: false,
};
case 'TODO_DELETED': {
const { [action.id]: _removed, ...rest } = state.todoMap;
return {
...state,
todoIds: state.todoIds.filter(id => id !== action.id),
todoMap: rest,
pending: false,
};
}
case 'FAILED':
return {
...state,
pending: false,
error: action.error,
};
case 'QUERY_CHANGED':
return {
...state,
query: action.query,
};
default:
throw new Error('unknown action type');
}
};
const asyncActionHandlers = {
CREATE_TODO: dispatch => async action => {
try {
dispatch({ type: 'STARTED' });
const response = await fetch(`https://reqres.in/api/todos?delay=1`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title: action.title }),
});
const data = await response.json();
if (typeof data.id !== 'string') throw new Error('no id');
if (typeof data.title !== 'string') throw new Error('no title');
dispatch({ type: 'TODO_CREATED', todo: data });
} catch (error) {
dispatch({ type: 'FAILED', error });
}
},
TOGGLE_TODO: (dispatch, getState) => async action => {
try {
dispatch({ type: 'STARTED' });
const todo = getState().todoMap[action.id];
const body = {
...todo,
completed: !todo.completed,
};
const response = await fetch(
`https://reqres.in/api/todos/${action.id}?delay=1`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
);
const data = await response.json();
if (typeof data.title !== 'string') throw new Error('no title');
dispatch({ type: 'TODO_UPDATED', todo: { ...data, id: action.id } });
} catch (error) {
dispatch({ type: 'FAILED', error });
}
},
DELETE_TODO: dispatch => async action => {
try {
dispatch({ type: 'STARTED' });
await fetch(`https://reqres.in/api/todos/${action.id}?delay=1`, {
method: 'DELETE',
});
dispatch({ type: 'TODO_DELETED', id: action.id });
} catch (error) {
dispatch({ type: 'FAILED', error });
}
},
};
const useValue = () =>
useReducerAsync(reducer, initialState, asyncActionHandlers);
export const {
Provider,
useTrackedState,
useUpdate: useDispatch,
} = createContainer(useValue);

This is the store we use. It is a bit long and you would eventually want to split into files. It defines a reducer for normal (sync) actions. Then, we combine it with async actioin handlers to create a store.

In this example we use use-reducer-async helper hook. It's a tiny custom hook, and actually it's fairly easy to do the same thing without the custom hook.

You could also use redux-saga for async actions. For saga users, here is an example.

Another note in this store is that it has both todoIds and todoMap. They are denormalized. The reason for this pattern is it would allow state usage tracking easier. In other words, we might not need React.memo in a certain case. If you want the data in the store to be normalized, please check out the array pattern in the other Tutorial.

src/TodoList.js

import React from 'react';
import { useDispatch, useTrackedState } from './store';
import TodoItem from './TodoItem';
import NewTodo from './NewTodo';
const TodoList = () => {
const dispatch = useDispatch();
const state = useTrackedState();
const setQuery = event => {
dispatch({ type: 'QUERY_CHANGED', query: event.target.value });
};
return (
<div>
{state.error && <h1>{state.error.message}</h1>}
<ul>
{state.todoIds.map(id => (
<TodoItem key={id} id={id} />
))}
<NewTodo />
</ul>
<div>
Highlight Query for incomplete items:
<input value={state.query} onChange={setQuery} />
</div>
{state.pending && <h3>Processing...</h3>}
</div>
);
};
export default TodoList;

This component is to show the list of TodoItems, NewTodo to create a new item, and a text field for highlight query. It will also show error and pending states.

Notice it only passes id to TodoItem.

src/TodoItem.js

import React from 'react';
import { useDispatch, useTrackedState } from './store';
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 }) => {
const dispatch = useDispatch();
const state = useTrackedState();
const todo = state.todoMap[id];
const delTodo = () => {
dispatch({ type: 'DELETE_TODO', id: todo.id });
};
return (
<li>
<input
type="checkbox"
checked={!!todo.completed}
onChange={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.completed ? todo.title : renderHighlight(todo.title, state.query)}
</span>
<button onClick={delTodo}>Delete</button>
</li>
);
};
export default React.memo(TodoItem);

This is the TodoItem component. It dispathes async actions, but it doesn't need to know if an action is sync or async.

src/NewTodo.js

import React, { useState } from 'react';
import { useDispatch } from './store';
const NewTodo = () => {
const dispatch = useDispatch();
const [text, setText] = useState('');
const addTodo = () => {
dispatch({ type: 'CREATE_TODO', title: text });
setText('');
};
return (
<li>
<input
value={text}
placeholder="Enter title..."
onChange={e => setText(e.target.value)}
/>
<button onClick={addTodo}>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.

CodeSandbox

You can try working example.