Chapter 4. Functional Excel
Remember function components? At some point in Chapter 2, as soon as state came into the picture, function components dropped out of the discussion. It’s time to bring them back.
A Quick Refresher: Function versus Class Components
In its simplest form a class component only needs one render()
method. This is where you build the UI, optionally using this.props
and this.state
:
class
Widget
extends
React
.
Component
{
render
()
{
let
ui
;
// fun with this.props and this.state
return
<
div
>
{
ui
}
</
div
>;
}
}
In a function component the whole component is the function and the UI is whatever the function returns. The props are passed to the function when the component is constructed:
function
Widget
(
props
)
{
let
ui
;
// fun with props but where's the state?
return
<
div
>
{
ui
}
</
div
>;
}
The usefulness of function components ended with React v16.8: you can use them only for components that don’t maintain state (stateless components). But with the addition of hooks in v16.8, it’s now possible to use function components everywhere. Through the rest of this chapter you’ll see how the Excel
component from Chapter 3 can be implemented as a function component.
Rendering the Data
The first step is to render the data passed to the component (Figure 4-1). How the component is used doesn’t change. In other words, a developer using your component doesn’t need to know if it’s a class or a function component. The initialData
and headers
props look the same. Even the propTypes
definitions are the same.
function
Excel
(
props
)
{
// implement me...
}
Excel
.
propTypes
=
{
headers
:
PropTypes
.
arrayOf
(
PropTypes
.
string
)
,
initialData
:
PropTypes
.
arrayOf
(
PropTypes
.
arrayOf
(
PropTypes
.
string
))
,
};
const
headers
=
[
'Book'
,
'Author'
,
'Language'
,
'Published'
,
'Sales'
];
const
data
=
[
[
'A Tale of Two Cities'
,
'Charles Dickens'
,
// ...
]
,
// ...
];
ReactDOM
.
render
(
<
Excel
headers
=
{
headers
}
initialData
=
{
data
}
/>,
document
.
getElementById
(
'app'
)
,
);
Implementing the body of the function component is largely copy-pasting the body of the render()
method of the class component:
function
Excel
({
headers
,
initialData
})
{
return
(
<
table
>
<
thead
>
<
tr
>
{
headers
.
map
((
title
,
idx
)
=>
(
<
th
key
=
{
idx
}
>
{
title
}
</
th
>
))}
</
tr
>
</
thead
>
<
tbody
>
{
initialData
.
map
((
row
,
idx
)
=>
(
<
tr
key
=
{
idx
}
>
{
row
.
map
((
cell
,
idx
)
=>
(
<
td
key
=
{
idx
}
>
{
cell
}
</
td
>
))}
</
tr
>
))}
</
tbody
>
</
table
>
);
}
In the code above you can see that instead of function Excel(props){}
you can use destructuring syntax function Excel({headers, initialData}){}
to save typing of props.headers
and props.initialData
later on.
The State Hook
To be able to maintain state in your function components, you need hooks. What’s a hook? It’s a function prefixed with the word use*
that lets you use various React features, such as tools for managing state and component lifecycles. You can also create your own hooks. By the end of this chapter you’ll learn how to use several built-in hooks as well as write your own.
Let’s start with the state hook. It’s a function called useState()
that’s available as a property of the React
object (React.useState()
). It takes one value, the initial value of a state variable (a piece of data you want to manage), and returns an array of two elements (a tuple). The first element is the state variable and the second is a function to change this variable. Let’s see an example.
In a class component, in the constructor()
you define the initial value like so:
this
.
state
=
{
data
:
initialData
;
};
Later on, when you want to change the data
state, you can instead do the following:
this
.
setState
({
data
:
newData
,
});
In a function component, you both define the initial state and get an updater function:
const
[
data
,
setData
]
=
React
.
useState
(
initialData
);
Note
Note the array destructuring syntax where you assign the two elements of the array returned by useState()
to two variables: data
and setData
. It’s a shorter and cleaner way to get the two return values, as opposed to, say:
const
stateArray
=
React
.
useState
(
initialData
);
const
data
=
stateArray
[
0
];
const
setData
=
stateArray
[
1
];
For rendering, you can now use the variable data
. When you want to update this variable, use:
setData
(
newData
);
Rewriting the component to use the state hook can now look like this:
function
Excel
({
headers
,
initialData
})
{
const
[
data
,
setData
]
=
React
.
useState
(
initialData
);
return
(
<
table
>
<
thead
>
<
tr
>
{
headers
.
map
((
title
,
idx
)
=>
(
<
th
key
=
{
idx
}
>
{
title
}
</
th
>
))}
</
tr
>
</
thead
>
<
tbody
>
{
data
.
map
((
row
,
idx
)
=>
(
<
tr
key
=
{
idx
}
>
{
row
.
map
((
cell
,
idx
)
=>
(
<
td
key
=
{
idx
}
>
{
cell
}
</
td
>
))}
</
tr
>
))}
</
tbody
>
</
table
>
);
}
Even though this example (see 04.02.fn.table-state.html) doesn’t use setData()
, you can see how it’s using the data
state. Let’s move on to sorting the table, where you’ll need the means to change the state.
Sorting the Table
In a class component, all the various bits of state go into the this.state
object, a grab bag of often unrelated pieces of information. Using the state hook you can still do the same, but you can also decide to keep pieces of state in different variables. When it comes to sorting a table, the data
contained in the table is one piece of information while the auxiliary sorting-specific information is another piece. In other words, you can use the state hook as many times as you want.
function
Excel
({
headers
,
initialData
})
{
const
[
data
,
setData
]
=
React
.
useState
(
initialData
);
const
[
sorting
,
setSorting
]
=
React
.
useState
({
column
:
null
,
descending
:
false
,
});
// ....
}
The data
is what you display in the table; the sorting
object is a separate concern. It’s about how you sort (ascending or descending) and by which column (title, author, etc.).
The function that does the sorting is now inline inside the Excel
function:
function
Excel
({
headers
,
initialData
})
{
// ..
function
sort
(
e
)
{
// implement me
}
return
(
<
table
>
{
/* ... */
}
</
table
>
);
}
The sort()
function figures out which column to sort by (using its index) and whether the sorting is descending:
const
column
=
e
.
target
.
cellIndex
;
const
descending
=
sorting
.
column
===
column
&&
!
sorting
.
descending
;
Then, it clones the data
array because it’s still a bad idea to modify the state directly:
const
dataCopy
=
clone
(
data
);
Note
A reminder that the clone()
function is still the quick and dirty JSON encode/decode way of deep copying:
function
clone
(
o
)
{
return
JSON
.
parse
(
JSON
.
stringify
(
o
));
}
The actual sorting is the same as before:
dataCopy
.
sort
((
a
,
b
)
=>
{
if
(
a
[
column
]
===
b
[
column
])
{
return
0
;
}
return
descending
?
a
[
column
]
<
b
[
column
]
?
1
:
-
1
:
a
[
column
]
>
b
[
column
]
?
1
:
-
1
;
});
And finally, the sort()
function needs to update the two pieces of state with the new values:
setData
(
dataCopy
);
setSorting
({
column
,
descending
});
And that’s about it for the business of sorting. What’s left is just to update the UI (the return value of the Excel()
function) to reflect which column is used for sorting and to handle clicks on any of the headers:
<
thead
onClick
=
{
sort
}
>
<
tr
>
{
headers
.
map
((
title
,
idx
)
=>
{
if
(
sorting
.
column
===
idx
)
{
title
+=
sorting
.
descending
?
' \u2191'
:
' \u2193'
;
}
return
<
th
key
=
{
idx
}
>
{
title
}
</
th
>;
})}
</
tr
>
</
thead
>
You can see the result with the sorting arrow in Figure 4-2.
You may have noticed another nice thing about using state hooks: there’s no need to bind any callback functions like you do in the constructor of a class component. None of this this.sort = this.sort.bind(this)
business. No this
, no constructor()
. A function is all you need to define a component.
Editing Data
As you remember from Chapter 3, the editing functionality consists of the following steps:
-
You double-click a table cell and it turns into a text input form.
-
You type in the text input form.
-
When done, you press Enter to submit the form.
To keep track of this process, let’s add an edit
state object. It’s null
when there’s no editing; otherwise, it stores the row and column indices of the cell being edited.
const
[
edit
,
setEdit
]
=
useState
(
null
);
In the UI you need to handle double-clicks (onDoubleClick={showEditor}
) and, if the user is editing, show a form. Otherwise, show only the data. When the user hits Enter, you trap the submit event (onSubmit={save}
).
<
tbody
onDoubleClick
=
{
showEditor
}
>
{
data
.
map
((
row
,
rowidx
)
=>
(
<
tr
key
=
{
rowidx
}
data
-
row
=
{
rowidx
}
>
{
row
.
map
((
cell
,
columnidx
)
=>
{
if
(
edit
&&
edit
.
row
===
rowidx
&&
edit
.
column
===
columnidx
)
{
cell
=
(
<
form
onSubmit
=
{
save
}
>
<
input
type
=
"text"
defaultValue
=
{
cell
}
/>
</
form
>
);
}
return
<
td
key
=
{
columnidx
}
>
{
cell
}
</
td
>;
})}
</
tr
>
))}
</
tbody
>
There are two short functions left to be implemented: showEditor()
and save()
.
The showEditor()
is invoked on double-clicking a cell in the table body. There you update the edit
state (via setEdit()
) with row and column indexes, so the rendering knows which cells to replace with a form.
function
showEditor
(
e
)
{
setEdit
({
row
:
parseInt
(
e
.
target
.
parentNode
.
dataset
.
row
,
10
)
,
column
:
e
.
target
.
cellIndex
,
});
}
The save()
function traps the form submit event, prevents the submission, and updates the data
state with the new value in the cell being edited. It also calls
setEdit()
passing null
as the new edit state, which means the editing is complete.
function
save
(
e
)
{
e
.
preventDefault
();
const
input
=
e
.
target
.
firstChild
;
const
dataCopy
=
clone
(
data
);
dataCopy
[
edit
.
row
][
edit
.
column
]
=
input
.
value
;
setEdit
(
null
);
setData
(
dataCopy
);
}
And with this, the editing functionality is finished. Consult 04.04.fn.table-edit.html in the book’s repo for the complete code.
Searching
Searching/filtering the data doesn’t pose any new challenges when it comes to React and hooks. You can try to implement it yourself and reference the implementation in 04.05.fn.table-search.html in the book’s repo.
You’ll need two new pieces of state:
-
The boolean
search
to signify whether the user is filtering or just looking at the data -
The copy of
data
aspreSearchData
, because nowdata
becomes a filtered subset of all data
const
[
search
,
setSearch
]
=
useState
(
false
);
const
[
preSearchData
,
setPreSearchData
]
=
useState
(
null
);
You need to take care of keeping preSearchData
updated, since data
(the filtered subset) can be updated when the user is editing while also filtering. Consult Chapter 3 as a refresher.
Let’s move on to implementing the replay feature, which provides a chance to become familiar with two new concepts:
-
Using lifecycle hooks
-
Writing your own hooks
Lifecycles in a World of Hooks
The replay feature in Chapter 3 uses two lifecycle methods of the Excel
class:
componentDidMount()
and componentWillUnmount()
.
Troubles with Lifecycle Methods
If you revisit the 03.14.table-fetch.html example, you may notice each of those has two tasks, unrelated to each other:
componentDidMount
()
{
document
.
addEventListener
(
'keydown'
,
this
.
keydownHandler
);
fetch
(
'https://www...'
)
.
then
(
/*...*/
)
.
then
((
initialData
)
=>
{
/*...*/
this
.
setState
({
data
});
});
}
componentWillUnmount
()
{
document
.
removeEventListener
(
'keydown'
,
this
.
keydownHandler
);
clearInterval
(
this
.
replayID
);
}
In componentDidMount()
you set up a keydown
listener to initiate the replay and also fetch data from a server. In componentWillUnmount()
you remove the keydown
listener and also clean up a setInterval()
ID. This illustrates two problems related to the use of lifecycle methods in class components (which are resolved when using hooks):
- Unrelated tasks are implemented together
-
For example, performing data fetching and setting up event listeners in one place. This makes the lifecycle methods grow in length while performing the unrelated tasks. In simple components this is fine, but in larger ones you need to resort to code comments or moving pieces of code to various other functions, so you can split up the unrelated tasks and make the code more readable.
- Related tasks are spread out
-
For example, consider adding and removing the same event listener. As the lifecycle methods grow in size, it’s harder to consider the separate pieces of the same concern at a glance because they simply don’t fit in the same screen of code when you read it later.
useEffect()
The built-in hook that replaces both of the lifecycle methods above is React.useEffect()
.
Note
The word “effect” stands for “side effect,” meaning a type of work that is unrelated to the main task but happens around the same time. The main task of any React component is to render something based on state and props. But rendering at the same time (in the same function) alongside a few side jobs (such as fetching data from a server or setting up event listeners) may be necessary.
In the Excel
component, for example, setting up a keydown
handler is a side effect of the main task of rendering data in a table.
The hook useEffect()
takes two arguments:
-
A callback function that is called by React at the opportune time
-
An optional array of dependencies
The list of dependencies contains variables that will be checked before the callback is invoked and dictate whether the callback should even be invoked.
-
If the values of the dependent variables have not changed, there’s no need to invoke the callback.
-
If the list of dependencies is an empty array, the callback is called only once, similarly to
componentDidMount()
. -
If the dependencies are omitted, the callback is invoked on every rerender
useEffect
(()
=>
{
// logs only if `data` or `headers` have changed
console
.
log
(
Date
.
now
());
},
[
data
,
headers
]);
useEffect
(()
=>
{
// logs once, after initial render, like `componentDidMount()`
console
.
log
(
Date
.
now
());
},
[]);
useEffect
(()
=>
{
// called on every re-render
console
.
log
(
Date
.
now
());
},
/* no dependencies here */
);
Cleaning Up Side Effects
Now you know how to use hooks to accomplish what componentDidMount()
has to offer in class components. But what about an equivalent to componentWillUnmount()
? For this task, you use the return value from the callback function you pass to useEffect()
:
useEffect
(()
=>
{
// logs once, after initial render, like `componentDidMount()`
console
.
log
(
Date
.
now
());
return
()
=>
{
// log when the component will be removed form the DOM
// like `componentDidMount()`
console
.
log
(
Date
.
now
());
};
},
[]);
Let’s see a more complete example (04.06.useEffect.html in the repo):
function
Example
()
{
useEffect
(()
=>
{
console
.
log
(
'Rendering <Example/>'
,
Date
.
now
());
return
()
=>
{
// log when the component will be removed form the DOM
// like `componentDidMount()`
console
.
log
(
'Removing <Example/>'
,
Date
.
now
());
};
}
,
[]);
return
<
p
>
I
am
an
example
child
component
.
</
p
>;
}
function
ExampleParent
()
{
const
[
visible
,
setVisible
]
=
useState
(
false
);
return
(
<
div
>
<
button
onClick
=
{()
=>
setVisible
(
!
visible
)}
>
Hello
there
,
press
me
{
visible
?
'again'
:
''
}
</
button
>
{
visible
?
<
Example
/>
:
null
}
</
div
>
);
}
Clicking the button once renders a child component and clicking it again removes it. As you can see in Figure 4-3, the return value of useEffect()
(which is a function) is invoked when the component is removed from the DOM.
Note that the cleanup (a.k.a. teardown) function was called when the component is removed from the DOM because the dependency array is empty. If there were a value in the dependency array, the teardown function would be called whenever the dependency value changes.
Trouble-Free Lifecycles
If you consider again the use case of setting up and clearing event listeners, it can be implemented like so:
useEffect
(()
=>
{
function
keydownHandler
()
{
// do things
}
document
.
addEventListener
(
'keydown'
,
keydownHandler
);
return
()
=>
{
document
.
removeEventListener
(
'keydown'
,
keydownHandler
);
};
},
[]);
The pattern above solves the second problem with class-based lifecycle methods mentioned previously—the problem of spreading related tasks all around the component. Here you can see how using hooks allows you to have the handler function, its setup, and its removal, all in the same place.
As for the the first problem (having unrelated tasks in the same place), this is solved by having multiple useEffect
calls, each dedicated to a specific task. Similarly to how you can have separate pieces of state instead of one grab-bag object, you can also have separate useEffect
calls, each addressing a separate concern, as opposed to a single class method that needs to take care of everything:
function
Example
()
{
const
[
data
,
setData
]
=
useState
(
null
);
useEffect
(()
=>
{
// fetch() and then call setData()
});
useEffect
(()
=>
{
// event handlers
});
return
<
div
>
{
data
}
<
/div>;
}
useLayoutEffect()
To wrap up the discussion of useEffect()
let’s consider another built-in hook called useLayoutEffect()
.
Note
There are just a few built-in hooks, so don’t worry about having to memorize a long list of new APIs.
useLayoutEffect()
works like useEffect()
, the only difference being that it’s invoked before React is done painting all the DOM nodes of a render. In general, you should use useEffect()
unless you need to measure something on the page (maybe dimensions of a rendered component or scrolling position after an update) and then rerender based on this information. When none of this is required, useEffect()
is better as it’s asynchronous and also indicates to the reader of your code that DOM mutations are not relevant to your component.
Because useLayoutEffect()
is called sooner, you can recalculate and rerender and the user sees only the last render. Otherwise, they see the initial render first, then the second render. Depending on how complicated the layout use, users may perceive a flicker between the two renders.
The next example (04.07.useLayoutEffect.html in the repo) renders a long table with random cell widths (just to make it harder for the browser). Then the width of the table is set in an effect hook.
function
Example
({
layout
})
{
if
(
layout
===
null
)
{
return
null
;
}
if
(
layout
)
{
useLayoutEffect
(()
=>
{
const
table
=
document
.
getElementsByTagName
(
'table'
)[
0
];
console
.
log
(
table
.
offsetWidth
);
table
.
width
=
'250px'
;
}
,
[]);
}
else
{
useEffect
(()
=>
{
const
table
=
document
.
getElementsByTagName
(
'table'
)[
0
];
console
.
log
(
table
.
offsetWidth
);
table
.
width
=
'250px'
;
}
,
[]);
}
return
(
<
table
>
<
thead
>
<
tr
>
<
th
>
Random
</
th
>
</
tr
>
</
thead
>
<
tbody
>
{
Array
.
from
(
Array
(
10000
)).
map
((
_
,
idx
)
=>
(
<
tr
key
=
{
idx
}
>
<
td
width
=
{
Math
.
random
()
*
800
}
>
{
Math
.
random
()}
</
td
>
</
tr
>
))}
</
tbody
>
</
table
>
);
}
function
ExampleParent
()
{
const
[
layout
,
setLayout
]
=
useState
(
null
);
return
(
<
div
>
<
button
onClick
=
{()
=>
setLayout
(
false
)}
>
useEffect
</
button
>
{
' '
}
<
button
onClick
=
{()
=>
setLayout
(
true
)}
>
useLayoutEffect
</
button
>
{
' '
}
<
button
onClick
=
{()
=>
setLayout
(
null
)}
>
clear
</
button
>
<
Example
layout
=
{
layout
}
/>
</
div
>
);
}
Depending on whether you trigger the useEffect()
or useLayoutEffect()
path, you may see a flicker as the table is being resized from its random value (around 600 px) to the hardcoded 250 px (see Figure 4-4).
Note that in both cases, you’re able to get the geometry of the table (e.g., table.offsetWidth
), so if you need this only for information purposes and you’re not going to rerender, you’re better off with the asynchronous useEffect()
. useLayoutEffect()
should be reserved for avoiding flicker in cases where you need to act (rerender) based on something you measure, for example, positioning a fancy tooltip component based on the size of the element it’s pointing to.
A Custom Hook
Let’s go back to Excel
and see how to go about implementing the replay feature. In the case of class components, it was necessary to create a logSetState()
and then replace all this.setState()
calls with this.logSetState()
. With function components you can replace all calls to the useState()
hook with useLoggedState()
. This is a bit more convenient since there are just a few calls (for every independent bit of state) and they are all at the top of the function.
// before
function
Excel
({
headers
,
initialData
})
{
const
[
data
,
setData
]
=
useState
(
initialData
);
const
[
edit
,
setEdit
]
=
useState
(
null
);
// ... etc
}
// after
function
Excel
({
headers
,
initialData
})
{
const
[
data
,
setData
]
=
useLoggedState
(
initialData
,
true
);
const
[
edit
,
setEdit
]
=
useLoggedState
(
null
);
// ... etc
}
There is no built-in useLoggedState()
hook, but that’s OK. You can create your own custom hooks.
Like the built-in hooks, a custom hook is just a function that starts with use*()
. Here’s an example:
function
useLoggedState
(
initialValue
,
isData
)
{
// ...
}
The signature of the hook can be anything you want. In this case, there’s an additional isData
argument. Its purpose is to help differentiate data state versus non-data state. In the class component example from Chapter 3 all the state is a single object, but here several pieces of the state are present. In the replay feature, the main goal is to show the data changes and then show that all the supporting info (sorting, descending, etc.) is secondary. Since the replay is updated every second, it won’t be as fun to watch the supporting data change individually; the replay would be too slow. So let’s have a main log (dataLog
array) and an auxiliary one (auxLog
array). In addition, it is useful to include a flag indicating whether the state changes because of user interaction or (automatically) during replay:
let
dataLog
=
[];
let
auxLog
=
[];
let
isReplaying
=
false
;
The custom hook’s goal is not to interfere with the regular state updates, so it delegates this responsibility to the original useState
. The goal is to log the state together with a reference to the function that knows how to update this state during replay. The function looks something like this:
function
useLoggedState
(
initialValue
,
isData
)
{
const
[
state
,
setState
]
=
useState
(
initialValue
);
// fun here...
return
[
state
,
setState
];
}
The code above is using the default useState
. But now you have the references to a piece of state and the means to update it. You need to log that. Let’s benefit from the useEffect()
hook here:
function
useLoggedState
(
initialValue
,
isData
)
{
const
[
state
,
setState
]
=
useState
(
initialValue
);
useEffect
(()
=>
{
// todo
}
,
[
state
]);
return
[
state
,
setState
];
}
This method ensures that the logging happens only when the value of state
changes. The useLoggedState()
function may be called a number of times during various rerenders, but you can ignore these calls unless they involve a change in an interesting piece of state.
In the callback of useEffect()
you:
-
Don’t do anything if the user is replaying.
-
Log every change to the data state to
dataLog
. -
Log every change to supporting data to
auxLog
, indexed by the associated change in data.
useEffect
(()
=>
{
if
(
isReplaying
)
{
return
;
}
if
(
isData
)
{
dataLog
.
push
([
clone
(
state
)
,
setState
]);
}
else
{
const
idx
=
dataLog
.
length
-
1
;
if
(
!
auxLog
[
idx
])
{
auxLog
[
idx
]
=
[];
}
auxLog
[
idx
].
push
([
state
,
setState
]);
}
}
,
[
state
]);
Why do custom hooks exist? They help you isolate and neatly package a piece of logic that is used in a component and often shared between components. The custom useLoggedState()
above can be dropped into any component that can benefit from logging its state. Additionally, custom hooks can call other hooks, which regular (non-hook and non-component) functions cannot.
Wrapping up the Replay
Now that you have a custom hook that logs the changes to various bits of state, it’s time to plug in the replay feature.
The replay()
function is not an exciting aspect of the React discussion, but it sets up an interval ID. You need that ID to clean up the interval in the event that Excel
gets removed from the DOM while replaying. In the replay, the data changes are replayed every second, while the auxiliary ones are flushed together:
function
replay
()
{
isReplaying
=
true
;
let
idx
=
0
;
replayID
=
setInterval
(()
=>
{
const
[
data
,
fn
]
=
dataLog
[
idx
];
fn
(
data
);
auxLog
[
idx
]
&&
auxLog
[
idx
].
forEach
((
log
)
=>
{
const
[
data
,
fn
]
=
log
;
fn
(
data
);
});
idx
++
;
if
(
idx
>
dataLog
.
length
-
1
)
{
isReplaying
=
false
;
clearInterval
(
replayID
);
return
;
}
},
1000
);
}
The final bit of plumbing is to set up an effects hook. After Excel
renders, the hook is responsible for setting up listeners that monitor the particular. combination of keys to start the replay show. This is also the place to clean up after the component is destroyed.
useEffect
(()
=>
{
function
keydownHandler
(
e
)
{
if
(
e
.
altKey
&&
e
.
shiftKey
&&
e
.
keyCode
===
82
)
{
// ALT+SHIFT+R(eplay)
replay
();
}
}
document
.
addEventListener
(
'keydown'
,
keydownHandler
);
return
()
=>
{
document
.
removeEventListener
(
'keydown'
,
keydownHandler
);
clearInterval
(
replayID
);
dataLog
=
[];
auxLog
=
[];
};
},
[]);
To see the code in its entirety, check out 04.08.fn.table-replay.html in the book’s repo.
useReducer
Let’s wrap up the chapter with one more built-in hook called useReducer()
. Using a reducer is an alternative to useState()
. Instead of various parts of the component calling changing state, all changes can be handled in a single location.
A reducer is just a JavaScript function that takes two inputs—the old state and an action—and returns the new state. Think of the action as something that has happened in the app, maybe a click, data fetch, or timeout. Something has happened and it requires a change. All three of the variables (new state, old state, action) can be of any type, though most commonly they are objects.
Reducer Functions
A reducer function in its simplest form looks like this:
function
myReducer
(
oldState
,
action
)
{
const
newState
=
{};
// do something with `oldState` and `action`
return
newState
;
}
Imagine that the reducer function is responsible for making sense of the reality when something happens in the world. The world is a mess
, then an event
happens. The function that should makeSense()
of the world reconciles the mess with the new event and reduces all the complexity to a nice state or order
:
function
makeSense
(
mess
,
event
)
{
const
order
=
{};
// do something with mess and event
return
order
;
}
Another analogy comes from the world of cooking. Some sauces and soups are called reductions too, produced by the process of reduction (thickening, intensifying the flavor). The initial state is a pot of water, then various actions (boiling, adding ingredients, stirring) alter the state of the contents of the pot with every action.
Actions
The reducer function can take anything (a string, an object), but a common implementation is an event
object with:
-
A
type
(e.g.,click
in the DOM world) -
Optionally, some
payload
of other information about the event
Actions are then “dispatched.” When the action is dispatched, the appropriate reducer function is called by React with the current state and your new event (action).
With useState
you have:
const
[
data
,
setData
]
=
useState
(
initialData
);
Which can be replaced with the reducer:
const
[
data
,
dispatch
]
=
useReducer
(
myReducer
,
initialData
);
The data
is still used the same way to render the component. But when something happens, instead of doing a bit of work followed by a call to setData()
, you call the dispatch()
function returned by useReducer()
. From there the reducer takes over and returns the new version of data
. There’s no other function to call to set the new state; the new data
is used by React to rerender the component.
Figure 4-5 shows a diagram of this process.
An Example Reducer
Let’s see a quick, isolated example of using a reducer. Say you have a table of random data together with buttons that can either refresh the data or change the table’s background and foreground colors to random ones (as depicted in Figure 4-6).
Initially, there’s no data and black and white colors are used as defaults:
const
initialState
=
{
data
:
[],
color
:
'black'
,
background
:
'white'
};
The reducer is initialized at the top of the component <RandomData>
:
function
RandomData
()
{
const
[
state
,
dispatch
]
=
useReducer
(
myReducer
,
initialState
);
// ...
}
Here, we’re back to state
being a grab-bag object of various state pieces (but that doesn’t need to be the case). The rest of the component is business-as-usual, rendering based on state
, with one difference. Where before you’d have a button’s onClick
handler be a function that updates the state, now all handlers just call
dispatch()
, sending information about the event:
return
(
<
div
>
<
div
className
=
"toolbar"
>
<
button
onClick
=
{()
=>
dispatch
({
type
:
'newdata'
})}
>
Get
data
</
button
>
{
' '
}
<
button
onClick
=
{()
=>
dispatch
({
type
:
'recolor'
,
payload
:
{
what
:
'color'
}})}
>
Recolor
text
</
button
>
{
' '
}
<
button
onClick
=
{
()
=>
dispatch
({
type
:
'recolor'
,
payload
:
{
what
:
'background'
}})
}
>
Recolor
background
</
button
>
</
div
>
<
table
style
=
{{
color
,
background
}}
>
<
tbody
>
{
data
.
map
((
row
,
idx
)
=>
(
<
tr
key
=
{
idx
}
>
{
row
.
map
((
cell
,
idx
)
=>
(
<
td
key
=
{
idx
}
>
{
cell
}
</
td
>
))}
</
tr
>
))}
</
tbody
>
</
table
>
</
div
>
);
Every dispatched event/action object has a type
property, so the reducer function can identify what needs to be done. There may or may not be a payload
specifying further details of the event.
Finally, the reducer. It has a number of if
/else
statements (or a switch
, if that’s your preference) that check what type of event it was sent. Then the data is manipulated according to the action and a new version of the state is returned:
function
myReducer
(
oldState
,
action
)
{
const
newState
=
clone
(
oldState
);
if
(
action
.
type
===
'recolor'
)
{
newState
[
action
.
payload
.
what
]
=
`rgb(
${
rand
(
256
)
}
,
${
rand
(
256
)
}
,
${
rand
(
256
)
}
)`
;
}
else
if
(
action
.
type
===
'newdata'
)
{
const
data
=
[];
for
(
let
i
=
0
;
i
<
10
;
i
++
)
{
data
[
i
]
=
[];
for
(
let
j
=
0
;
j
<
10
;
j
++
)
{
data
[
i
][
j
]
=
rand
(
10000
);
}
}
newState
.
data
=
data
;
}
return
newState
;
}
// couple of helpers
function
clone
(
o
)
{
return
JSON
.
parse
(
JSON
.
stringify
(
o
));
}
function
rand
(
max
)
{
return
Math
.
floor
(
Math
.
random
()
*
max
);
}
Note how the old state is being cloned using the quick-and-dirty clone()
you already know. With useState()/setState()
this wasn’t strictly necessary in a lot of cases. You could often get by with modifying an existing variable and passing it to setState()
. But here if you don’t clone and merely modify the same object in memory, React will see old and new state as pointing to the same object and will skip the render, thinking nothing has changed. You can try for yourself: remove the call to clone()
and observe that the rerendering is not happening.
Unit Testing Reducers
Switching to useReducer()
for state management makes it much easier to write unit tests. You don’t need to set up the component and its properties and state. You don’t need to get a browser involved or find another way to simulate click events. You don’t even need to get React involved at all. To test the state logic, all you need to do is pass both the old state and an action to the reducer function and check if the desired new state is returned. This is pure JavaScript: two objects in, one object out. The unit tests should not be much more complicated than testing the canonical example:
function
add
(
a
,
b
)
{
return
a
+
b
;
}
There’s a discussion on testing later in the book, but just to give you a taste, a sample test could look like so:
const
initialState
=
{
data
:
[],
color
:
'black'
,
background
:
'white'
};
it
(
'produces a 10x10 array'
,
()
=>
{
const
{
data
}
=
myReducer
(
initialState
,
{
type
:
'newdata'
});
expect
(
data
.
length
).
toEqual
(
10
);
expect
(
data
[
0
].
length
).
toEqual
(
10
);
});
Excel Component with a Reducer
For one last example of using reducers, let’s see how you can switch from
useState()
to useReducer()
in the Excel
component.
In the example from the previous section, the state managed by the reducer was again an object of unrelated data. It doesn’t have to be this way. You can have multiple reducers to separate your concerns. You can even mix and match useState()
with useReducer()
. Let’s try this with Excel
.
Previously the data
in the table was managed by useState()
:
const
[
data
,
setData
]
=
useState
(
initialData
);
// ...
const
[
edit
,
setEdit
]
=
useState
(
null
);
const
[
search
,
setSearch
]
=
useState
(
false
);
Switching to useReducer()
for managing data
while leaving the rest untouched looks like the following:
const
[
data
,
dispatch
]
=
useReducer
(
reducer
,
initialData
);
// ...
const
[
edit
,
setEdit
]
=
useState
(
null
);
const
[
search
,
setSearch
]
=
useState
(
false
);
Since data
is the same, there’s no need to change anything in the rendering section. Changes are required only in the action handlers. For example, filter()
is used to do the filtering and call setData()
:
function
filter
(
e
)
{
const
needle
=
e
.
target
.
value
.
toLowerCase
();
if
(
!
needle
)
{
setData
(
preSearchData
);
return
;
}
const
idx
=
e
.
target
.
dataset
.
idx
;
const
searchdata
=
preSearchData
.
filter
((
row
)
=>
{
return
row
[
idx
].
toString
().
toLowerCase
().
indexOf
(
needle
)
>
-
1
;
});
setData
(
searchdata
);
}
The rewritten version dispatches an action instead. The event has a type
of “search” and some additional payload (what is the user searching for, and where?):
function
filter
(
e
)
{
const
needle
=
e
.
target
.
value
;
const
column
=
e
.
target
.
dataset
.
idx
;
dispatch
({
type
:
'search'
,
payload
:
{
needle
,
column
},
});
setEdit
(
null
);
}
Another example would be toggling the search fields:
// before
function
toggleSearch
()
{
if
(
search
)
{
setData
(
preSearchData
);
setSearch
(
false
);
setPreSearchData
(
null
);
}
else
{
setPreSearchData
(
data
);
setSearch
(
true
);
}
}
// after
function
toggleSearch
()
{
if
(
!
search
)
{
dispatch
({
type
:
'startSearching'
});
}
else
{
dispatch
({
type
:
'doneSearching'
});
}
setSearch
(
!
search
);
}
Here you can see the mix of setSearch()
and dispatch()
to manage the state. The
!search
toggle is a flag for the UI to show or hide input boxes, while the dispatch()
is for managing the data.
Finally, let’s take a look at the the reducer()
function. This is where all the data filtering and manipulation happens now. It’s again a series of if
/else
blocks, each handling a different action type:
let
originalData
=
null
;
function
reducer
(
data
,
action
)
{
if
(
action
.
type
===
'sort'
)
{
const
{
column
,
descending
}
=
action
.
payload
;
return
clone
(
data
).
sort
((
a
,
b
)
=>
{
if
(
a
[
column
]
===
b
[
column
])
{
return
0
;
}
return
descending
?
a
[
column
]
<
b
[
column
]
?
1
:
-
1
:
a
[
column
]
>
b
[
column
]
?
1
:
-
1
;
});
}
if
(
action
.
type
===
'save'
)
{
data
[
action
.
payload
.
edit
.
row
][
action
.
payload
.
edit
.
column
]
=
action
.
payload
.
value
;
return
data
;
}
if
(
action
.
type
===
'startSearching'
)
{
originalData
=
data
;
return
originalData
;
}
if
(
action
.
type
===
'doneSearching'
)
{
return
originalData
;
}
if
(
action
.
type
===
'search'
)
{
return
originalData
.
filter
((
row
)
=>
{
return
(
row
[
action
.
payload
.
column
]
.
toString
()
.
toLowerCase
()
.
indexOf
(
action
.
payload
.
needle
.
toLowerCase
())
>
-
1
);
});
}
}
Get React: Up & Running, 2nd Edition now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.