Introduction
When it comes to state management in a React app the name “Redux” is the go-to solution in almost all projects. But always managing the state with Redux can be overkill sometimes causing complex and additional code to the code bundle. React already comes with pretty simple and powerful tools that can help us manage complex states. Using the combination of Context, useReducer, and custom hooks can be a great alternative to redux.
In this blog, we will build a ToDo app in React and manage its state using Context API, useReducer hook, and Custom hook. Which I think is the best way possible.
What you should know
- React
- Context API
- useReducer
- How to make custom hooks.
Architecture

Todo Context => then create a context to provide the Todo State throughout the application.
useTodoState() => This will be a custom hook that we can call in any component we want and use the required Todo state slice.
useTodoAction() => This will be a custom hook that will contain all the dispatcher functions we need to call to update the state.
UI Components
In this blog, we will not discuss how the UI components we built because that is not our focus. Following UI components are already made:-
1) Todo List
2) Todo List Item
3) Add Todo Input
The State
To store and update our state, we will use the useReducer hook. In the useReducer hook, we need to pass the initial state and the reducer function to update the state.
This is how our state object will like:-
{
data: [], // holds the actual list of todos
isLoading: false, // the loading state if we are integrating with some API
error: false, // to hold the error if any
}
Our todo item object will be like this:-
{
id: number,
title: string,
done: boolean,
}
This is our reducer function:-
function todoReducer(state, action) {
switch (action.type) {
case 'add':
return {
...state,
data: [
...state.data,
{ name: action.payload, done: false, id: state.data.length + 1 },
],
};
case 'delete':
return {
...state,
data: state.data.filter((todo) => todo.id !== action.payload)
};
case 'complete':
return {
...state,
data: state.data.map((todo) => {
if (todo.id === action.payload) {
todo.done = true;
return todo;
}
return todo;
}),
};
case 'not_complete':
return {
...state,
data: state.data.map((todo) => {
if (todo.id === action.payload) {
todo.done = false;
return todo;
}
return todo;
}),
};
default:
return {
data: [],
isLoading: false,
error: false,
}
}
}
Now we will create a custom hook to export the this reducer’s state and dispatch function in an object
export function useTodo(initialState = []) {
const [state, dispatch] = React.useReducer(todoReducer, {
data: initialState,
isLoading: false,
error: false,
});
return {
state,
dispatch,
};
}
The Context
Now to provide the above-created state to the whole application we need to pass it in a context and wrap the whole application with this context’s provider
import { useTodo } from './todoReducer';
const TodoContext = React.createContext();
function ContextsProvider(props) {
return (
<TodoContext.Provider value={useTodo()}>
{props.children}
</TodoContext.Provider>
);
}
Now we will wrap the whole app with our provider.
index.js
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
<StrictMode>
<ContextsProvider>
<App />
</ContextsProvider>
</StrictMode>
);
The Selector ( useTodoState() )
Now like we have selectors in Redux similarly we will create a custom hook state that will act as a selector and we will not need to repeat ourselves when we want to use the Todo state in a component.
function useTodoState() {
const { state } = React.useContext(TodoContext);
const completed = state.data.filter((todo) => todo.done).length;
return {
todos: state.data,
isLoading: state.isLoading,
error: state.error,
totalTodos: state.data.length,
completedTodos: completed,
todosLeft: state.data.length - completed,
};
}
As you can see we can get really creative with this and create some custom logic to return some enhanced version of the state. Like in this we have also calculated the number of totals, completed, and left todos.
The Actions ( useTodoAction() )
Similarly, we will create a custom that will return the functions a component can use to dispatch the state update actions. Right now I will just a function to add a new todo and later as we progress will the other function based on the use case.
function useTodoAction() {
const { dispatch } = React.useContext(TodoContext);
function addTodo(title) {
// api call can be added here
dispatch({
type: 'add',
payload: title,
});
}
return {
addTodo,
};
}
Complete feature to add a todo item
Our App.js file holds all the UI components. In it, we have a
1) Input field to type in the title of the new todo.
2) A button to submit the new todo.
3) <TodoList />
So in TodoList.js, we will import our useTodoState so that we can render the list of <TodoItem /> in it, and in our App.js we will import our useTodoAction so that on submit we can call the function to add the new todo.
TodoList.js
const TodoList = React.memo(() => {
const { todos } = useTodoState();
return (
<div>
{todos.map((todo) => {
return <TodoItem key={todo.id} todo={todo} />;
})}
</div>
);
});
App.js
export default function App() {
const { addTodo } = useTodoAction();
const [newTodoName, setNewTodoName] = React.useState('');
const { completedTodos, totalTodos } = useTodoState();
return (
<div>
<span>
<input
value={newTodoName}
onChange={(e) => setNewTodoName(e.target.value)}
/>
<button
onClick={() => {
addTodo(newTodoName);
setNewTodoName('');
}}
>
add
</button>
<br/>
</span>
<br />
{' '}
{completedTodos} / {totalTodos}{' '}
<br />
<TodoList />
</div>
);
}
Result

After submitting:-

Feature to complete todo
Now since we have a fair amount of idea how the whole data flow works we can build the rest of the features.
useTodoAction.js
function useTodoAction() {
const { state, dispatch } = React.useContext(TodoContext);
function addTodo(title) {
...
}
function toggleTodo(todo) {
// api call can be added here
dispatch({
type: todo.done ? 'not_complete' : 'complete',
payload: todo.id,
});
}
return {
toggleTodo,
addTodo,
};
}
TodoItem.js
const TodoItem = ({ todo }) => {
const { toggleTodo } = useTodoAction();
const style = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
border: '1px solid gray',
marginBottom: '3px',
};
return (
<div style={style}>
<span>
<input
type="checkbox"
value={todo.done}
onChange={() => toggleTodo(todo)}
/>
{todo.name}
</span>
<button>delete</button>
</div>
);
};
Result

After checked:-

Feature to delete todo
useTodoAction
function useTodoAction() {
const { state, dispatch } = React.useContext(TodoContext);
function addTodo(title) {
...
}
function toggleTodo(todo) {
...
}
function deleteTodo(todo) {
dispatch({
type: 'delete',
payload: todo.id,
});
}
return {
toggleTodo,
addTodo,
deleteTodo
};
}
TodoItem.js
const TodoItem = ({ todo }) => {
const { toggleTodo, deleteTodo } = useTodoAction();
const style = {
..
};
return (
<div style={style}>
<span>
<input
type="checkbox"
value={todo.done}
onChange={() => toggleTodo(todo)}
/>
{todo.name}
</span>
<button onClick={() => deleteTodo(todo)}>delete</button>
</div>
);
};
Result

After deleting:-

Conclusion
To conclude, I think we can very well use context useReducer to create a very lightweight solution for state management in React. And next time you might want to consider not choosing redux just because that is the only option and create a state management logic for yourself.
Useful links
React hooks
https://reactjs.org/docs/hooks-reference.html