Functional React components, a primer, part 2

by
Tags: , , , ,
Category:

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:

  1. Sets up the timer
  2. Runs the timer periodically, changing the component's state (created from useState), which causes a re-render that cycles the color
  3. 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.