State Logic কে একটি Reducer এ স্থানান্তর করা
একাধিক event handler এ ছড়িয়ে থাকা একাধিক state update ওয়ালা কম্পোনেন্টগুলো দুঃসহ হয়ে যেতে পারে। এসব ক্ষেত্রে, আপনি সকল state update logic কে আপনার কম্পোনেন্টের বাইরে একটিমাত্র function এ একত্রিত করতে পারেন, যাকে বলা হয় reducer।
যা যা আপনি শিখবেন
- reducer function বলতে কী বুঝায়
- কিভাবে
useState
কে গুছিয়েuseReducer
এ পরিণত করা যায় - কখন reducer ব্যবহার করতে হয়
- কীভাবে একে ভালভাবে লিখতে হয়
State logic কে একটি reducer এ একত্র করুন
ধীরে ধীরে যখন আপনার কম্পোনেন্টগুলোর জটিলতা বাড়তে থাকে, তখন এক নজর দেখে এটা বোঝা কঠিন হয়ে যেতে পারে যে কতোনা উপায়ে একটা কম্পোনেন্টের state আপডেট হতে পারে। উদাহরণস্বরূপ, নিচের TaskApp
কম্পোনেন্টটি tasks
নামক array কে state হিসেবে ধারণ করে, আর কোনো task কে add, edit, remove করার জন্য তিনটি ভিন্ন ভিন্ন event handler এর ব্যবহার করে:
import { useState } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, setTasks] = useState(initialTasks); function handleAddTask(text) { setTasks([ ...tasks, { id: nextId++, text: text, done: false, }, ]); } function handleChangeTask(task) { setTasks( tasks.map((t) => { if (t.id === task.id) { return task; } else { return t; } }) ); } function handleDeleteTask(taskId) { setTasks(tasks.filter((t) => t.id !== taskId)); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
এর প্রতিটি event handler state কে আপডেট করার জন্য setTasks
কে call করে। ধীরে ধীরে যখন এ কম্পোনেন্টটি আকারে বাড়তে থাকবে, তখন সাথে সাথে এর ভিতরকার state logic ও বাড়তে থাকবে এবং জটিলতর হতে থাকবে। এই জটিলতা কমাতে এবং আপনার সব state logic একটি সহজে-পাওয়া-যায় এমন জায়গায় রাখতে, আপনি ঐসব state logic কে আপনার কম্পোনেন্টের বাইরে একটি function এ স্থানান্তর করতে পারেন, যে function টিকে বলা হয় “reducer”.
Reducer হলো state হ্যান্ডেল করার একটি বিকল্প পদ্ধতি। আপনি useState
থেকে useReducer
এ তিনটি ধাপে স্থানান্তর করতে পারেন:
- state কে set করার বদলে action কে dispatch করতে শুরু করুন।
- একটি reducer function লিখুন।
- reducer টিকে আপনার কম্পোনেন্ট থেকে ইউজ করুন।
ধাপ ১: State কে set করার বদলে action কে dispatch করতে শুরু করুন
State কে set করার মাধ্যমে আপনার event handler গুলো বর্তমানে নির্ধারণ করছে যে কী করতে হবে:
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
এখন সব state সেট করার logic দূর করে দিন। এখন আপনার কাছে যা বাকি থাকবে তা হলো:
- ইউজার যখন “Add” প্রেস করে তখন call করা হয়
handleAddTask(text)
। - ইউজার যখন “Save” প্রেস করে কিংবা কোনো task কে toggle (বা edit) করে তখন call করা হয়
handleChangeTask(task)
। - ইউজার যখন “Delete” প্রেস করে তখন call করা হয়
handleDeleteTask(taskId)
।
Reducer দিয়ে state ম্যানেজ করা, state সেট করা থেকে কিছুটা ভিন্ন জিনিস। React কে state সেট করার মাধ্যমে “কী করতে হবে” না বলে, আপনি আপনার event handler গুলো থেকে “action” গুলোকে dispatch করার মাধ্যমে ঠিক করে দেন “ইউজার এইমাত্র কী করলো”। (আর state update logic অন্য আরেক জায়গায় থাকবে!) তাই একটি event handler এর মাধ্যমে ”tasks
সেট করার” পরিবর্তে, আপনি “একটি task add/change/delete করার” action(কাজ) dispatch করবেন। আর এই পদ্ধতিটি ইউজারের আকাঙ্ক্ষাকে বেশি বর্ণনা করে।
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
আপনি dispatch
এর কাছে যে object টি pass করেন, তাকে একটি “action” বলে:
function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}
এটি একটি সাধারণ JavaScript object। এর মধ্যে কী রাখতে হবে সেটা আপনার উপর, তবে স্বাভাবিকভাবে এর মধ্যে কী ঘটলো(what happened) সে ব্যপারে ন্যূনতম ইনফর্মেশন থাকতে হবে। (আর আপনি dispatch
ফাংশনটিকে পরবর্তী একটি ধাপে যুক্ত করবেন।)
ধাপ ২: একটি reducer function লিখুন
একটি reducer function হলো যেখানে আপনি আপনার state লজিক রাখবেন। এটি দুটি argument নেয়, বর্তমান state এবং action অবজেক্ট, অতঃপর এটি পরবর্তী state কে return করেঃ
function yourReducer(state, action) {
// return next state for React to set
}
আপনি reducer থেকে যা return করবেন, React সেটিকে state হিসেবে সেট করে দিবে।
এই উদাহরণে, state সেট করার লজিককে event handlers থেকে একটি reducer function এ সরাতে, আপনার:
- বর্তমান state (
tasks
) কে প্রথম argument হিসেবে declare করতে হবে। action
অবজেক্টকে দ্বিতীয় argument হিসেবে declare করতে হবে।- reducer থেকে পরবর্তী state কে return করতে হবে। (যেটিকে React পরবর্তী state হিসেবে সেট করবে)
সব state সেট করার লজিক reducer function এ সরানোর পর এমন দেখাবেঃ
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Unknown action: ' + action.type);
}
}
যেহেতু reducer function টি state (tasks
) কে একটি argument হিসেবে নিচ্ছে, আপনি একে আপনার কম্পোনেন্টের বাইরে declare করতে পারবেন। এটা indentation level কমিয়ে আনে এবং আপনার কোডকে পড়তে সহজ করে।
গভীরভাবে জানুন
Although reducers can “reduce” the amount of code inside your component, they are actually named after the reduce()
operation that you can perform on arrays.
The reduce()
operation lets you take an array and “accumulate” a single value out of many:
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
The function you pass to reduce
is known as a “reducer”. It takes the result so far and the current item, then it returns the next result. React reducers are an example of the same idea: they take the state so far and the action, and return the next state. In this way, they accumulate actions over time into state.
You could even use the reduce()
method with an initialState
and an array of actions
to calculate the final state by passing your reducer function to it:
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Visit Kafka Museum'}, {type: 'added', id: 2, text: 'Watch a puppet show'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Lennon Wall pic'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
You probably won’t need to do this yourself, but this is similar to what React does!
Step 3: Use the reducer from your component
Finally, you need to hook up the tasksReducer
to your component. Import the useReducer
Hook from React:
import { useReducer } from 'react';
Then you can replace useState
:
const [tasks, setTasks] = useState(initialTasks);
with useReducer
like so:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
The useReducer
Hook is similar to useState
—you must pass it an initial state and it returns a stateful value and a way to set state (in this case, the dispatch function). But it’s a little different.
The useReducer
Hook takes two arguments:
- A reducer function
- An initial state
And it returns:
- A stateful value
- A dispatch function (to “dispatch” user actions to the reducer)
Now it’s fully wired up! Here, the reducer is declared at the bottom of the component file:
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [ ...tasks, { id: action.id, text: action.text, done: false, }, ]; } case 'changed': { return tasks.map((t) => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter((t) => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
If you want, you can even move the reducer to a different file:
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import tasksReducer from './tasksReducer.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
Component logic can be easier to read when you separate concerns like this. Now the event handlers only specify what happened by dispatching actions, and the reducer function determines how the state updates in response to them.
Comparing useState
and useReducer
Reducers are not without downsides! Here’s a few ways you can compare them:
- Code size: Generally, with
useState
you have to write less code upfront. WithuseReducer
, you have to write both a reducer function and dispatch actions. However,useReducer
can help cut down on the code if many event handlers modify state in a similar way. - Readability:
useState
is very easy to read when the state updates are simple. When they get more complex, they can bloat your component’s code and make it difficult to scan. In this case,useReducer
lets you cleanly separate the how of update logic from the what happened of event handlers. - Debugging: When you have a bug with
useState
, it can be difficult to tell where the state was set incorrectly, and why. WithuseReducer
, you can add a console log into your reducer to see every state update, and why it happened (due to whichaction
). If eachaction
is correct, you’ll know that the mistake is in the reducer logic itself. However, you have to step through more code than withuseState
. - Testing: A reducer is a pure function that doesn’t depend on your component. This means that you can export and test it separately in isolation. While generally it’s best to test components in a more realistic environment, for complex state update logic it can be useful to assert that your reducer returns a particular state for a particular initial state and action.
- Personal preference: Some people like reducers, others don’t. That’s okay. It’s a matter of preference. You can always convert between
useState
anduseReducer
back and forth: they are equivalent!
We recommend using a reducer if you often encounter bugs due to incorrect state updates in some component, and want to introduce more structure to its code. You don’t have to use reducers for everything: feel free to mix and match! You can even useState
and useReducer
in the same component.
Writing reducers well
Keep these two tips in mind when writing reducers:
- Reducers must be pure. Similar to state updater functions, reducers run during rendering! (Actions are queued until the next render.) This means that reducers must be pure—same inputs always result in the same output. They should not send requests, schedule timeouts, or perform any side effects (operations that impact things outside the component). They should update objects and arrays without mutations.
- Each action describes a single user interaction, even if that leads to multiple changes in the data. For example, if a user presses “Reset” on a form with five fields managed by a reducer, it makes more sense to dispatch one
reset_form
action rather than five separateset_field
actions. If you log every action in a reducer, that log should be clear enough for you to reconstruct what interactions or responses happened in what order. This helps with debugging!
Writing concise reducers with Immer
Just like with updating objects and arrays in regular state, you can use the Immer library to make reducers more concise. Here, useImmerReducer
lets you mutate the state with push
or arr[i] =
assignment:
import { useImmerReducer } from 'use-immer'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; function tasksReducer(draft, action) { switch (action.type) { case 'added': { draft.push({ id: action.id, text: action.text, done: false, }); break; } case 'changed': { const index = draft.findIndex((t) => t.id === action.task.id); draft[index] = action.task; break; } case 'deleted': { return draft.filter((t) => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } } export default function TaskApp() { const [tasks, dispatch] = useImmerReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
Reducers must be pure, so they shouldn’t mutate state. But Immer provides you with a special draft
object which is safe to mutate. Under the hood, Immer will create a copy of your state with the changes you made to the draft
. This is why reducers managed by useImmerReducer
can mutate their first argument and don’t need to return state.
পুনরালোচনা
- To convert from
useState
touseReducer
:- Dispatch actions from event handlers.
- Write a reducer function that returns the next state for a given state and action.
- Replace
useState
withuseReducer
.
- Reducers require you to write a bit more code, but they help with debugging and testing.
- Reducers must be pure.
- Each action describes a single user interaction.
- Use Immer if you want to write reducers in a mutating style.
Challenge 1 of 4: Dispatch actions from event handlers
Currently, the event handlers in ContactList.js
and Chat.js
have // TODO
comments. This is why typing into the input doesn’t work, and clicking on the buttons doesn’t change the selected recipient.
Replace these two // TODO
s with the code to dispatch
the corresponding actions. To see the expected shape and the type of the actions, check the reducer in messengerReducer.js
. The reducer is already written so you won’t need to change it. You only need to dispatch the actions in ContactList.js
and Chat.js
.
import { useReducer } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; import { initialState, messengerReducer } from './messengerReducer'; export default function Messenger() { const [state, dispatch] = useReducer(messengerReducer, initialState); const message = state.message; const contact = contacts.find((c) => c.id === state.selectedId); return ( <div> <ContactList contacts={contacts} selectedId={state.selectedId} dispatch={dispatch} /> <Chat key={contact.id} message={message} contact={contact} dispatch={dispatch} /> </div> ); } const contacts = [ {id: 0, name: 'Taylor', email: 'taylor@mail.com'}, {id: 1, name: 'Alice', email: 'alice@mail.com'}, {id: 2, name: 'Bob', email: 'bob@mail.com'}, ];