Redux is one of the most popular state management libraries in the React ecosystem. If you’re not familiar with the state management concept, it’s a layer added to your project that maintains and shares data across all components.
In Redux, we typically create predefine states in the reducer and dispatch required updates to all subscribed components. For example:
const initialState = {
title: "Do groceries",
completed: false,
};
export const todoReducer = (state = initialState, action) => {
switch (action.type) {
case "UPDATE_TITLE": {
return {
...state,
title: action.value,
};
}
default:
return state;
}
};
The above code is a simple reducer that updates a to-do item. Usually, we have a dynamic to-do list so instead, we can store a list and return a new immutable version when the user updates a value:
const initialState = {
todoList: [
{ id: 1, title: "Do groceries", completed: false },
{ id: 2, title: "Do shopping", completed: false },
],
};
export const todoReducer = (state = initialState, action) => {
switch (action.type) {
case "UPDATE_TODO_LIST": {
return {
...state,
todoList: [...action.value],
};
}
default:
return state;
}
};
And the component will have a change handler that updates the item based on the id. Afterwards, it will dispatch the changes:
export const TodoList = () => {
const todoList = useSelector(state => state.todoList)
const dispatch = useDispatch();
function changeHandler(e, id) {
const todoItem = todoList.find(item => item.id === id);
todoItem.title = e.target.value;
dispatch({
type: "UPDATE_TODO_LIST",
value: todoList,
});
}
return (
<article>
{todoList.map((item) => (
<section key={item.id}>
<input type="text" value={item.title} onChange={(e) => changeHandler(e, item.id)} />
</section>
))}
</article>
);
};
Although this approach works just fine and React’s virtual DOM is efficient when re-rendering lists, we might get performance issues as the application grows bigger and the data structure becomes more complex. Especially when the component is a multi-column editable grid that contains nested children.
In this tutorial, we will explain how to dynamically create and update states in Redux that will enable us to surgically edit data in a grid without re-rendering the whole list.
The tutorial assumes that you have a basic understanding of Redux. If not, please go through the overview on the official site.
Create a basic todo component
We are going to create a component with a real API call and display the list in a table element. Let’s go through the required steps:
- Create an
async
function to trigger an API call. We’re going to use {JSON} Placeholder service that returns a fake to-do list.
async function getTodoList() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos");
}
- Add the API function in the
useEffect
hook to be triggered when the component is ready and then update thetodoList
state
useEffect(() => {
async function getTodoList() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos");
setTodoList(await response.json());
}
getTodoList();
}, []);
- Create a
table
element and loop through the data to return dynamictr
andtd
elements
<table>
<thead>
<tr>
<th>Task</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{todoList.map((item) => (
<tr key={item.id}>
<td>{item.title}</td>
<td>{item.completed}</td>
</tr>
))}
</tbody>
</table>
Full component
import { useState, useEffect } from "react";
import "./todo.css";
export const TodoList = () => {
const [todoList, setTodoList] = useState([]);
useEffect(() => {
async function getTodoList() {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos"
);
setTodoList(await response.json());
}
getTodoList();
}, []);
return (
<table>
<thead>
<tr>
<th>Task</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{todoList.map((item) => (
<tr key={item.id}>
<td>{item.title}</td>
<td>{item.completed}</td>
</tr>
))}
</tbody>
</table>
);
};
So far, we have a simple two-column and read-only to-do list. Keeping reading, it will get more exciting I promise 😊
Add dynamic states
The key to updating a specific item without re-rendering the whole list is to create dynamic states based on ids (similar to a dictionary). The initial state in the reducer will be an empty object and as soon as the data is ready in the component we’ll re-generate the data as a key/value based object, for example:
{
1: {
title: 'Do groceries',
completed: false
},
2: {
title: 'Do shopping',
completed: true
},
3: {
title: 'Go to the gym',
completed: false
}
}
Let’s create a reducer with a new action type that updates the data list:
const initialState = {};
export const todoReducer = (state = initialState, action) => {
switch (action.type) {
case "UPDATE_TODO_LIST": {
return action.value.reduce(
(accumulator, current) => ({ ...accumulator, [current.id]: current }),
{}
);
}
default:
return state;
}
};
As you can see above, we used the array’s reduce
function to re-generate the data to an id-based object.
Next, in the TodoList
component, we’ll dispatch the data right after the API call.
useEffect(() => {
async function getTodoList() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos");
const data = await response.json();
dispatch({
type: "UPDATE_TODO_LIST",
value: data,
});
setTodoList(data);
}
getTodoList();
}, []);
As soon as the component is ready, the reducer will convert the to-do list to a new object and update the state. Keep in mind that the process of calling the API and converting the data will only happen once.
Update input fields
Updating a specific input field in the table
list is now possible without re-rendering the Todo
component. We first need to add a new action type in the reducer that will update a specific id and field as follows:
const initialState = {};
export const todoReducer = (state = initialState, action) => {
switch (action.type) {
case "UPDATE_TODO_LIST": {
return action.value.reduce(
(accumulator, current) => ({ ...accumulator, [current.id]: current }),
{}
);
}
case "UPDATE_TODO_FIELD": {
return {
...state,
[action.id]: {
...state[action.id],
[action.field]: action.value,
},
};
}
default:
return state;
}
};
Using JavaScript’s dynamic key assignment and spread operator, we can modify the field of a specific to-do item. What is left now is to create custom input field components for both the “title” and “completed” properties. Let’s go through the required steps:
- Add two separate components, a checkbox for the “completed” field and an input text for the “title” field:
const TextBox = () => {
return <input type="text" />
}
const CheckBox = () => {
return <input type="checkbox" />
}
- Add
useSelector
that subscribes to an id and field:
const TextBox = ({id}) => {
const value = useSelector(state => state[id]?.title)
return <input type="text" value={value} />
}
const CheckBox = ({id}) => {
const value = useSelector(state => state[id]?.completed);
return <input type="checkbox" value={value} />
}
We’re accessing ids directly from the state (state[id]
) as we only have one main reducer in this example but in a real-life scenario, we’d probably have multiple combined reducers that can be accessed by name (state.myReducerName[id]
)
- Dispatch input changes to the new action that we created in the previous step and pass it the id, field, and value:
const TextBox = ({id}) => {
const value = useSelector(state => state[id]?.title)
const dispatch = useDispatch();
function changeHandler(e) {
dispatch({
type: 'UPDATE_TODO_FIELD',
id, value: e.target.value, field: 'title'
})
}
return <input type="text" value={value} onChange={changeHandler} />
}
const CheckBox = ({id}) => {
const value = useSelector(state => state[id]?.completed);
const dispatch = useDispatch();
function changeHandler(e) {
dispatch({
type: 'UPDATE_TODO_FIELD',
id, value: e.target.checked, field: 'completed'
})
}
return <input type="checkbox" value={value} onChange={changeHandler} />
}
Full component
import { useState, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import "./todo.css";
export const TodoList = () => {
const [todoList, setTodoList] = useState([]);
const dispatch = useDispatch();
useEffect(() => {
async function getTodoList() {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos"
);
const data = await response.json();
dispatch({
type: 'UPDATE_TODO_LIST',
value: data
});
setTodoList(data);
}
getTodoList();
}, []);
return (
<table>
<thead>
<tr>
<th>Task</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{todoList.map((item) => (
<tr key={item.id}>
<td><TextBox id={item.id}></TextBox></td>
<td><CheckBox id={item.id}></CheckBox></td>
</tr>
))}
</tbody>
</table>
);
};
const TextBox = ({id}) => {
const value = useSelector(state => state[id]?.title)
const dispatch = useDispatch();
function changeHandler(e) {
dispatch({
type: 'UPDATE_TODO_FIELD',
id, value: e.target.value, field: 'title'
})
}
return <input type="text" value={value} onChange={changeHandler} />
}
const CheckBox = ({id}) => {
const value = useSelector(state => state[id]?.completed);
const dispatch = useDispatch();
function changeHandler(e) {
dispatch({
type: 'UPDATE_TODO_FIELD',
id, value: e.target.checked, field: 'completed'
})
}
return <input type="checkbox" value={value} onChange={changeHandler} />
}
Demo
Summary
When the TodoList
component first renders and dispatches the data, we’re creating a dictionary based on ids. Initially, all input fields will be updated as they’re subscribed to that object. But, whenever the user does any modifications, only the component subscribed to the specific id/field will be rendered leaving us with optimum performance 🚀
Bye for now 👋