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.

rur2 0401
Figure 4-1. Rendering the table in a function component (see 04.01.fn.table.html)

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.

rur2 0402
Figure 4-2. Sorting the data (see 04.03.fn.table-sort.html)

Editing Data

As you remember from Chapter 3, the editing functionality consists of the following steps:

  1. You double-click a table cell and it turns into a text input form.

  2. You type in the text input form.

  3. 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 as preSearchData, because now data 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.use​Ef⁠fect().

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 componentWill​Un⁠mount()? 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.

rur2 0403
Figure 4-3. Using useEffect

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).

rur2 0404
Figure 4-4. Flickering rerender

Note that in both cases, you’re able to get the geometry of the table (e.g., table​.off⁠setWidth), so if you need this only for information purposes and you’re not going to rerender, you’re better off with the asynchronous useEffect(). useLayout​Ef⁠fect() 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.

rur2 0405
Figure 4-5. Component-dispatch-action-reducer flow

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>
);
rur2 0406
Figure 4-6. <RandomData/> component (04.09.random-table-reducer.html)

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.