Overview
In this article I'm going to show you how functional components handle events, state, and lifecycle, and how they can even be used to replace the use of Redux for external state management.
Managing non-state variables
One challenge you may run into is in managing references to objects you'd classically assign to this
, like a timer reference. Consider this (silly) component that cycles colors in a message:
import { Component } from "react"; const colors = ['red', 'brown', 'blue', 'green', 'black']; export default class CounterClassBased extends Component { state = { counter: 0 }; componentDidMount() { this.handle = setInterval(() => { this.setState({ counter: this.state.counter + 1}); }, 2000); } componentWillUnmount() { // clean up! clearInterval(this.handle); } // we mod by the length to get a count from 0..len - 1, // then use that to index into the 'colors' array to // set the color to display render() { return ( <p style={ { color: colors[this.state.counter % colors.length]}}> {message} </p> ); } }
This component cycles the colors of a message every 2 seconds. It also destroys the timer interval when the component is destroyed (whenever the component rendering it is destroyed, for example). It uses the class method onComponentWillUnmount
to do so.
We don't store the timer handle as state, because we're not using it for dirty checking. Instead, we store it in a normal member variable of the class. We need the handle to clean up the timer, because it won't go away automatically when the component is unloaded (timers are managed at the window level, so you'll get all sorts of errors when the still-extant function is attempting to access destroyed objects).
The Color Cycler, using functional components
Doing the same thing with functional components is somewhat easy, once you see an example. The useEffect
function will run when the component mounts, when a prop or state variable is changed, and it can be assigned a function to run when the component is unmounted.
The effect does several things:
- Sets up the timer
- Runs the timer periodically, changing the component's state (created from
useState
), which causes a re-render that cycles the color - Returns a cleanup function that kills the timer once the component begins to unload
Here is the same sample, coded using useEffect
:
import { useState, useEffect } from 'react'; const colors = ['red', 'brown', 'blue', 'green', 'black']; const ColorMessageFunctional = ({message}) => { let [counter, setCounter] = useState(0); useEffect(() => { // create our handle reference as a LOCAL variable! const counterTimer = setInterval(() => { setCounter(counter++); }, 2000); // define a cleanup function - called in the same // way componentWillUnmount() is called on a class- // based component return () => { // due to the power of JS closures, we can seee the counterTimer // in the wrapped function and use it to destroy the interval clearInterval(counterTimer); } }, []); return ( <p style={ { color: colors[counter % colors.length]}}> { message } </p> ); }; export default ColorMessageFunctional;
You CSS experts out there can send complaints to nobody@example.com
, because I know this is a very silly, terrible, contrived example. It's ok, I'd yell at me too.
References – hold onto resources but not in state variables
Suppose you're working with an API like the canvas, and you need to hold on to it. It's not a state variable, per-se, and it's not a prop you'd be passing in. You need access to it across various events.
To set up the reference in a class-based component, you'd define it in the constructor as a class member variable, and assign the ref
property on the compnent you attach to it.
constructor(props) { super(props); this.canvasRef = React.createRef(); } ... render() { ... return( <> <canvas ref={this.canvasRef} ...></canvas> </> }
Then the rest of the component can access the reference and get things done, like this:
recordPenDown = (e) => { const canvas = this.canvasRef.current; const context = canvas.getContext('2d'); context.beginPath(); const pos = getXYPosition(canvas, e); context.moveTo(pos.x, pos.y); }
So the current
property of the reference holds the assigned DOM element, and the reference just lives as a member variable of the class, not as a state or prop entry.
Functionally holding references with useRef
How would we do the same thing in a functional component?
It turns out that there is a useRef
hook which achieves the same result. Still bound with the ref
property on the element, most of our code looks the same. We just don't use a this
keyword and our event handling functions are local to the functional component body:
import { useEffect, useRef } from 'react'; // helper to manage canvas complexity import { getXYPosition } from '../../misc/canvas-utils'; export default function DrawingCanvasFunctional() { // create the reference - it will be bound to the // element once it is mounted const canvasRef = useRef(null); // configure the defaults for our drawing canvas useEffect(() => { const context = canvasRef.current.getContext('2d'); context.strokeStyle = 'black'; }) // simplified event handler... function recordPenDown(e) { const context = canvasRef.current.getContext('2d'); context.beginPath(); // helper function in my project const pos = getXYPosition(canvasRef.current, e); context.moveTo(pos.x, pos.y); } function recordPenUp(e) { const context = canvasRef.current.getContext('2d'); const pos = getXYPosition(canvasRef.current, e); context.lineTo(pos.x, pos.y); context.stroke(); } ... return ( <> <h1>Functional Canvas!</h1> <canvas style={{border: '1px solid blue'}} width="300px" height="400px" onMouseDown={recordPenDown} onMouseUp={recordPenUp} ref={canvasRef}> </canvas> </> ); }
As you can see, references provide useful access to HTML DOM elements, and are available both in class and functional settings.
Now let's focus on some advanced hooks. First, we'll look at a 3rd party forms API, react-forms-hook
, and how it uses hooks and functional components to simplify forms management.
Managing Forms
Because functional components don't have a consistent this
reference (as they are functions!), you can't rely on things like this.setState
or attach handlers to the objects. As we saw in my last post, event handlers can be added as functions defined within the functional component and then attached via the typical onSubmit
and onChange
events. We then tied the changes to state data we created in the useState
function.
A hooks-based forms management API, React Hook Form, makes building forms easy, and also uses uncontrolled components to reduce the amount of re-rendering between form keystrokes (as compared to Formik, which uses controlled components and renders the form on each keystroke).
A Form using React Hook Form
Here is the same form as detailed in the prior blog post, only using the useForm
hook provided by React Hook Form. The entire form is available here as part of the project for this blog post series.
We start with importing the hook:
import { useForm } from 'react-hook-form';
Then, we define the component and add the submit function. Note that it is just a normal function, nothing special. The submitted data will be passed to this function.
const DemoReactHookForm = () => { function doSubmit (data) { // do something! console.log('Submitted', JSON.stringify(data)); } ...
Now, we'll use the useForm
hook from React Hook Form to set up the functions and error validation routines:
// useForm provides our form lifecycle methods and settings const { register, // set up a field handleSubmit, // the submit handler wrapper reset, // the reset handler formState: { errors, isDirty, isValid } // errors are automatically managed } = useForm({ mode: 'onChange', defaultValues: { dateOfBirth: getDateTimeForInput() }, });
The useForm
hook returns a number of utility methods, such as register
, a form field reset
function, and handleSubmit
, as well as indicators for validation, such as managing individual field errors with the formState.errors
property, and flags of isDirty
and isValid
.
Next, we'll render the form itself. First, we set up the form itself. Note how we wrap our form handler method with handleSubmit
from React Hook Form.
return ( <div> <h1>Demo React Hook Form</h1> <p><em>View submitted data in JS Console</em></p> <form className={form} onSubmit={handleSubmit(doSubmit)}> ...
Now, we'll define each field using the register
function:
<div className={styles.formField}> <label htmlFor="firstName">First Name</label> <input name="firstName" type="text" {...register('firstName', {required: true})} /> { errors?.firstName?.type || '' } </div> <div className={styles.formField}> <label htmlFor="lastName">Last Name</label> <input name="lastName" type="text" {...register('lastName', {required: true, minLength: 4})} /> { errors.lastName?.type || '' } </div> <div className={styles.formField}> <label htmlFor="dateOfBirth">Date of Birth</label> <input name="dateOfBirth" type="datetime-local" step={1} {...register('dateOfBirth', {required: true })} /> { errors.dateOfBirth?.type || '' } </div>
Note: as found by Charioteer Matt Swartley, the native date field I’m using needs a step
property in order to properly provide the right data for the seconds field. The step of 1 is 1 second.
Error handling is a more advanced topic, but each error has a type
property, which we inspect in order to render it. You'll see as you go in and out of error conditions that the error will display on the right of the field.
Finally, we'll define our form action buttons. We'll let React Hook Form manage the form submission by attaching itself to the submit
form button type, and clearing itself with the reset
button type:
<div className={styles.buttonBar}> <button type="submit" disabled={!isValid}>Submit</button> <button type="button" onClick={() => reset()}>Clear</button> </div> </form> </div> ); }; export default DemoReactHookForm;
And that's it. With React Hook Form forms are handled robustly, while staying within the function-driven component development style.
Using React instead of Redux for state data
If you've been struggling to manage Redux-based applications because they seem to dump everything into a single giant state tree, React hooks can provide an alternative.
Redux, as a state management system, provides a dispatch
function to request an action to execute based on calling a reducer
, a function that either replaces the state of the data store with a new verision (with modified values) or leaves it alone.
A typical reducer in Redux looks something like this:
function calculatorReducer(state = 0, action) { switch (action.type) { case 'ADD': return state + action.value; case 'SUBTRACT': return state - action.value; default: return state; } }
In the example above, each reducer call is executed, passing the existing state tree, and the action to request. Each action has a type
field, which is used to figure out what to do. In all cases, the state is either replaced, or returned unmodified.
Any modification to the state causes all components attached to that state to re-render automatically.
The dispatch
method uses an action creator, a function that generates the second parameter to the reducer, with the property type
and zero or additional payload properties. For example, to dispatch the ADD
operation:
// manually create the action store.dispatch({ type: 'ADD', value: 10}); // using an action creator function, add, which generates // the shape above store.dispatch(add(10)); // the action creator sample: function add(value) { return { type: 'ADD', value: value }; }
The problem with Redux and state trees
In short, when scaling up a Redux-based React application, the state tree becomes larger and larger. You can organize the tree into multiple, smaller trees by composing them, but ultimately the size of the store is directly related to the number of objects stored within.
Consider what happens when a component dispatches a request to load a large number of rows from a database, and the user then navigates away from that component and doesn't come back to it again in that session. That memory is, in effect, wasted, unless the developer decides to clean up the store when leaving the component during componentWillUnmount
.
If a component and its children manages its state by passing props, in the traditional way, the complexity is shifted to prop and state management, which is the whole reason why Redux was created.
Enter the useReducer
hook
Instead of using a global state store with Redux, a higher level component can create its own state store, and provide it to all of the children automatically in much the same way that Redux does. The added benefit of having the component manage the reducer is that its data is automatically cleaned up when the component is destroyed. This is then a happy medium between prop-and-state management on one hand, and store-based management on the other.
To create your own reducer via a hook, you simply call useReducer
. First, set up a reducer function (with the same semantics as Redux):
import { useReducer } from "react"; import { v4 } from 'uuid'; const initialState = { tasks: [] }; function reducer(state, action) { switch(action.type) { case 'add': return { tasks: [...state.tasks, action.payload ] }; case 'remove': return { tasks: state.tasks.filter(t => t.id !== action.payload )}; } }
Then, define your component, using the useReducer
hook to provide your reducer, initial state, and a dispatch method to the component:
const TodoListManager = () => { const [state, dispatch] = useReducer(reducer, initialState);
Next, render your component, using the state
to iterate through the current tree of data, and the dispatch
method to send the reducer a command:
const taskList = state.tasks.map((task) => ( <tr key={task.id}> <td>{ task.id }</td> <td><button onClick={() => dispatch({ type: 'remove', payload: task.id })}>Remove</button></td> </tr> )); return ( <> <h1>Task Manager with <code>useReducer</code></h1> <table> <tbody> { taskList } </tbody> </table> <button onClick={() => dispatch({ type: 'add', payload: { id: v4() }})}>Add</button> </> );
That's a lot simpler than installing Redux, defining middlewares, and managing a giant state engine. On the downside, you don't have easy access to Redux devtools.
Or can you? There are several projects such as this one that are attempting to provide just this feature.
Wrap-up
That's my introduction to using functional React components via hooks. I made a sandbox project so you can play around with the code I used to write up the blog post, grab it at https://github.com/krimple/functional-vs-class-react-components.