React Function Components: Testable Code Patterns
Tags: javascript, react, webdev, testing
Published: 2022-01-04 13:52:16

## The Problem

The advent of function components has introduced new ways to think about component design in React. We can write code that's cleaner and easier to understand, while dispensing with a lot of the boilerplate code required by class components. This should be a win for developers (and hopefully for future code maintainers) but the patterns that have been demonstrated in many tutorials and adopted by many developers leave something to be desired: testability. Consider the example shown in Example 1.

#### Example 1

	import React, { useState } from 'react';
	import PropTypes from 'prop-types';

	const AddButton = (props) => {
	  const { initialNumber, addNumber } = props;
	  const [ sum, setSum ] = useState(initialNumber);

	  const addToSum = () => {
	  	setSum(sum + addNumber);
	  };

	  return (
	  	<button onClick={addToSum}>
	  	  Add {addNumber} to {sum}
	  	</button>
	  );
	};

	AddButton.defaultProps = {
	  initialNumber: 0,
	  addNumber: 1,
	};

	AddButton.propTypes = {
	  initialNumber: PropTypes.number.isRequired,
	  addNumber: PropTypes.number.isRequired,
	};

	export default AddButton;

This is a trivial component that adds a number to a sum each time a button is pressed &emdash; the sort of thing you'll find in a typical tutorial. The component accepts an initial number and the number to add as props. The initial number is set as the initial sum on state and each press of the button updates the sum by adding the number to it. There isn't much to this component. The business logic consists of the `addToSum` function, which amounts to a simple math expression whose result is passed to the `setSum` state setter. It should be very easy to test that this produces the correct result, but it isn't because `addToSum` is declared within the component's scope and can't be accessed from outside the component. Let's make a few small changes to fix that. Example 2 moves the logic into a separate function, so we can test that the math is correct.

#### Example 2

	// functions.js

	export const add = (a, b) => {
	  return a + b;
	};

	// functions.test.js

	import { add } from './functions';

	test('The add function calculates the sum of two numbers', () => {
	  const sum = add(4, 5);
	  expect(sum).toEqual(9);
	});

	// component.js

	import React, { useState } from 'react';
	import PropTypes from 'prop-types';
	import { add } from './functions';

	const AddButton = (props) => {
	  const { initialNumber, addNumber } = props;
	  const [ sum, setSum ] = useState(initialNumber);

	  const addToSum = () => {
	  	setSum(add(sum, addNumber));
	  };

	  return (
	  	<button onClick={addToSum}>
	  	  Add {addNumber} to {sum}
	  	</button>
	  );
	};

	AddButton.defaultProps = {
	  initialNumber: 0,
	  addNumber: 1,
	};

	AddButton.propTypes = {
	  initialNumber: PropTypes.number.isRequired,
	  addNumber: PropTypes.number.isRequired,
	};

	export default AddButton;

This is _slightly_ better. We can test that the sum will be calculated correctly but we still have that pesky `addToSum` function littering up our component and we still can't test that the sum is actually set on state. We can fix both of these problems by introducing a pattern that I call an _effect function_.

## Introducing Effect Functions

An effect function is really just a closure &emdash; a function that returns another function &emdash; in which the inner function has access to the outer function's scope. This pattern is nothing new. It has been widely used as a solution to scope problems in JavaScript for a long time. We're just going to put it to use to improve the structure and testability of our React components. I call it an effect function because of how it integrates with React's `useEffect` hook and other event handlers, which we'll see later on.

Example 3 builds on Example 2 by moving all the logic into an effect function called `addToSumEffect`. This cleans up the component nicely and allows us to write more comprehensive tests.

#### Example 3

	// functions.js

	export const add = (a, b) => {
	  return a + b;
	};

	// functions.test.js

	import { add } from './functions';

	test('The add function calculates the sum of two numbers', () => {
	  const sum = add(4, 2);
	  expect(sum).toEqual(6);
	});

	// effects.js

	import { add } from './functions';

	export const addToSumEffect = (options = {}) => {
	  const { addNumber, sum, setSum } = options;
	  return () => {
	  	setSum(add(sum, addNumber));
	  };
	};

	// effects.test.js

	import { addToSumEffect } from './effects';

	test('addToSumEffect returns a function', () => {
	  const addNumber = 4;
	  const sum = 2;
	  const setSum = jest.fn();
	  const func = addToSumEffect({ addNumber, sum, setSum });
	  expect(typeof func).toEqual('function');
	});

	test('The function returned by addToSumEffect calls setSum with the expected value', () => {
	  const addNumber = 4;
	  const sum = 2;
	  const setSum = jest.fn();
	  const func = addToSumEffect({ addNumber, sum, setSum });
	  func();
	  expect(setSum).toHaveBeenCalledTimes(1);
	  expect(setSum).toHaveBeenCalledWith(6);
	});

	// component.js

	import React, { useState } from 'react';
	import PropTypes from 'prop-types';
	import { addToSumEffect } from './effects';

	const AddButton = (props) => {
	  const { initialNumber, addNumber } = props;
	  const [ sum, setSum ] = useState(initialNumber);

	  return (
	  	<button onClick={addToSumEffect({ addNumber, sum, setSum })}>
	  	  Add {addNumber} to {sum}
	  	</button>
	  );
	};

	AddButton.defaultProps = {
	  initialNumber: 0,
	  addNumber: 1,
	};

	AddButton.propTypes = {
	  initialNumber: PropTypes.number.isRequired,
	  addNumber: PropTypes.number.isRequired,
	};

	export default AddButton;

The code has changed a lot compared to Example 1, so let's walk through it beginning with the component. The component imports `addToSumEffect` from a separate file and assigns its return value to the button's `onClick` prop. `addToSumEffect` is the closure's outer function. Its return value is the closure's inner function, which will be called when the button is pressed. `addToSumEffect` accepts an `options` hash containing the current values of `addNumber` and `sum`, as well as the `setSum` function. These arguments are unpacked in the outer function's scope, which makes them available to the inner function.

	export const addToSumEffect = (options = {}) => {
	  // Unpack arguments from the options hash in the outer function:
	  const { addNumber, sum, setSum } = options;
	  return () => {
	  	// The values are scoped into the inner function:
	  	setSum(add(sum, addNumber));
	  };
	};

The outer function is called on every render with the current `addNumber`, `sum` and `setSum` values, which generates a new inner function each time. This ensures that, whenever the button is pressed, it has access to the most up-to-date values from the component. This makes the inner function a sort of snapshot of the component values at the time the component was last rendered.

We can break this process down step by step for the sake of clarity:

1. The component renders
2. `addToSumEffect` is called with a hash of the current `addNumber`, `sum` and `setSum` values from the component
3. `addToSumEffect` returns a new function with the current `addNumber`, `sum` and `setSum` values in scope
4. The returned function is assigned to the button's `onClick` prop
5. The user presses or clicks the button and the returned function is called
6. The new sum is calculated from the current `sum` and `addNumber` values
7. The new sum is passed to `setSum` which updates the sum on the component's state
8. The component renders and the process begins again with the new value of `sum`

The behaviour of `addToSumEffect` should be stable and predictable for any given values of `sum` and `addNumber`. We can confirm this with tests.

## Testing Effect Functions

Example 3 defines the two tests for `addToSumEffect`. The first test simply confirms that `addToSumEffect` returns a function, which means that it conforms to the expected pattern.

	test('addToSumEffect returns a function', () => {
	  const addNumber = 4;
	  const sum = 2;
	  const setSum = jest.fn();
	  const func = addToSumEffect({ addNumber, sum, setSum });
	  expect(typeof func).toEqual('function');
	});

The second test calls the returned function, supplying a `jest.fn()` mock function for `setSum`, which enables us to test that `setSum` was called appropriately by the returned function. We expect `setSum` to have been called only once, with the sum of the `addNumber` and `sum` values. If the returned function calls `setSum` more than once (or not at all) or calls it with the incorrect value, the test will fail.

	test('The function returned by addToSumEffect calls setSum with the expected value', () => {
	  const addNumber = 2;
	  const sum = 4;
	  const setSum = jest.fn();
	  const func = addToSumEffect({ addNumber, sum, setSum });
	  func();
	  expect(setSum).toHaveBeenCalledTimes(1);
	  expect(setSum).toHaveBeenCalledWith(6);
	});

Note that we aren't testing the effect function's internal logic. We only care that `setSum` is called once with the expected sum. We don't care how the effect function arrives at that result. The internal logic can change as long as the result remains the same.

## Using Effect Functions with the `useEffect` Hook

There's one more small enhancement we can make to the component shown in Example 3. Currently, nothing happens if the `initialNumber` prop changes after the initial mount. If `initialNumber` changes, I'd like it to be set as the new value of `sum` on state. We can do that easily by declaring a new effect function called `initializeSumEffect` as shown in Example 4.

#### Example 4

	// functions.js

	export const add = (a, b) => {
	  return a + b;
	};

	// functions.test.js

	import { add } from './functions';

	test('The add function calculates the sum of two numbers', () => {
	  const sum = add(4, 2);
	  expect(sum).toEqual(6);
	});

	// effects.js

	import { add } from './functions';

	export const addToSumEffect = (options = {}) => {
	  const { addNumber, sum, setSum } = options;
	  return () => {
	  	setSum(add(sum, addNumber));
	  };
	};

	// NEW:
	export const initializeSumEffect = (options = {}) => {
	  const { initialNumber, setSum } = options;
	  return () => {
	  	setSum(initialNumber);
	  };
	};

	// effects.test.js

	import { initializeSumEffect, addToSumEffect } from './effects';

	// NEW:
	test('initializeSumEffect returns a function', () => {
	  const initialNumber = 4;
	  const setSum = jest.fn();
	  const func = initializeSumEffect({ initialNumber, setSum });
	  expect(typeof func).toEqual('function');
	});

	// NEW:
	test('The function returned by initializeSumEffect calls setSum with the value of initialNumber', () => {
	  const initialNumber = 4;
	  const setSum = jest.fn();
	  const func = initializeSumEffect({ initialNumber, setSum });
	  func();
	  expect(setSum).toHaveBeenCalledTimes(1);
	  expect(setSum).toHaveBeenCalledWith(initialNumber);
	});

	test('addToSumEffect returns a function', () => {
	  const addNumber = 4;
	  const sum = 2;
	  const setSum = jest.fn();
	  const func = addToSumEffect({ addNumber, sum, setSum });
	  expect(typeof func).toEqual('function');
	});

	test('The function returned by addToSumEffect calls setSum with the expected value', () => {
	  const addNumber = 4;
	  const sum = 2;
	  const setSum = jest.fn();
	  const func = addToSumEffect({ addNumber, sum, setSum });
	  func();
	  expect(setSum).toHaveBeenCalledTimes(1);
	  expect(setSum).toHaveBeenCalledWith(6);
	});

	// component.js

	import React, { useState, useEffect } from 'react';
	import PropTypes from 'prop-types';
	import { initializeSumEffect, addToSumEffect } from './effects';

	const AddButton = (props) => {
	  const { initialNumber, addNumber } = props;
	  const [ sum, setSum ] = useState(initialNumber);

	  // New:
	  useEffect(initializeSumEffect({ initialNumber, setSum }), [initialNumber]);

	  return (
	  	<button onClick={addToSumEffect({ addNumber, sum, setSum })}>
	  	  Add {addNumber} to {sum}
	  	</button>
	  );
	};

	AddButton.defaultProps = {
	  initialNumber: 0,
	  addNumber: 1,
	};

	AddButton.propTypes = {
	  initialNumber: PropTypes.number.isRequired,
	  addNumber: PropTypes.number.isRequired,
	};

	export default AddButton;

Let's break the new additions down step by step:

1. The component updates with a new value for the `initialNumber` prop
2. `initializeSumEffect` is called with a hash of the current `initialNumber` and `setSum` values from the component
3. `initializeSumEffect` returns a new function with the current `initialNumber` and `setSum` values in scope
4. The returned function is assigned to the `useEffect` hook (note that the hook is configured to run only when `initialNumber` has changed, not on every render)
5. The component renders
6. `useEffect` runs, calling the returned function
7. The `initialNumber` value is passed to `setSum` which updates the sum on the component's state
8. The component renders

We also have new tests to confirm that `initializeSumEffect` returns a function, and that the returned function calls `setSum` with the expected value.

Notice how similar `initializeSumEffect` is to `addToSumEffect` despite being used in different contexts. This is one of the benefits of this pattern. It works equally well whether you're working with React hooks, JavaScript event handlers, or both.

## A Less Trivial Example: API Integration

The examples above are simple, which made them a good introduction to the effect function pattern. Let's look at how to apply this pattern to more of a real world integration: an asychronous API request that updates component state upon completion.

The basic pattern for this is the same as the previous example. We'll use an effect function to perform the request when the component mounts, then set the response body (or error) on the component state. Everything the effect consumes will be passed in from the component, so the effect function won't have external dependencies that would make it harder to test.

#### Example 5

	// effects.js

	export const getDataEffect = (options = {}) => {
	  const { url, getJson, setData, setError, setIsLoading } = options;
	  return async () => {
	  	setIsLoading(true);
	  	try {
          const data = await getJson(url);
          setData(data);
          setError(null);
          setIsLoading(false);
	  	} catch (error) {
	  	  setError(error);
	  	  setIsLoading(false);
	  	}
	  };
	};

	// component.js

	import React, { useState, useEffect } from 'react';
	import { getDataEffect } from './effects';
	import { getJson } from './requests';
	import { LoadingIndicator } from './loading';
	import { DataView } from './data-view';

	const DataPage = (props) => {
	  const [ data, setData ] = useState({});
	  const [ error, setError ] = useState(null);
	  const [ isLoading, setIsLoading ] = useState({});

	  useEffect(
	  	getDataEffect({
	  	  url: 'https://api.myapp.com/data',
	  	  getJson,
	  	  setData,
	  	  setError,
	  	  setIsLoading
	    }),
	    []
	  );

	  return (
	  	<div className="data-page">
	  	  {isLoading && <LoadingIndicator />}
	  	  {error && (
	  	  	<p className="error-message">
	  	  	  {error.message}
	  	  	</p>
	  	  )}
	  	  {!error && (<DataView data={data} />)}
	  	</div>
	  );
	};

	export default DataPage;

Note that some elements in Example 5 are not described in detail because they don't fall within the scope of this discussion. `getJson` is an async function that makes an  `GET` request for some data and returns the data or throws an error. `LoadingIndicator` is a component that displays loading activity or progress UI. `DataView` is a component that displays the requested data. I have omitted these from the example so we can focus on the pattern. Let's break down the flow:

1. The component mounts.
2. `getDataEffect` is called with the request url, request function (`getJson`) and setters for the `data`, `error` and `isLoading` state values. `getDataEffect` returns an async function.
3. The `useEffect` hook calls the async function that was returned by `getDataEffect`.
4. The async function sets the loading state to `true`, which causes the loading indicator to render.
5. The async function calls `getJson` with the request url and waits for a response.
6. Upon receiving a successful response, the async function sets the data on state, the error state to `null` and the loading state to `false`. The component stops rendering the loading indicator and passes the data to `DataView` to be rendered.
7. If `getJson` throws an error, the async function sets the error on state and the loading state to `false`. The component stops rendering the loading indicator and renders an error message.

Next, let's add tests for `getDataEffect`:

#### Example 6:

	// effects.test.js

	import { getDataEffect } from './effects';

	test('getDataEffect returns a function', () => {
	  const url = 'https://fake.url';
	  const getJson = jest.fn();
	  const setData = jest.fn();
	  const setError = jest.fn();
	  const setIsLoading = jest.fn();
	  const func = getDataEffect({ url, getJson, setData, setError, setIsLoading });
	  expect(typeof func).toEqual('function');
	});

	test('The function returned by getDataEffect behaves as expected when making a successful request', async () => {
	  const url = 'https://fake.url';
	  const data = { status: true };

	  // Mock the async getJson function to resolve with the data:
	  const getJson = jest.fn();
	  getJson.mockReturnValue(Promise.resolve(data));

	  // Mock the setter functions:
	  const setData = jest.fn();
	  const setError = jest.fn();
	  const setIsLoading = jest.fn();

	  // Run the effect:
	  const func = getDataEffect({ url, getJson, setData, setError, setIsLoading });
	  await func();

	  // Test that getJson was called once with the provided url:
	  expect(getJson).toHaveBeenCalledTimes(1);
	  expect(getJson).toHaveBeenCalledWith(url);

	  // Test that setData was called once with the expected data:
	  expect(setData).toHaveBeenCalledTimes(1);
	  expect(setData).toHaveBeenCalledWith(data);

	  // Test that setError was called once with null:
	  expect(setError).toHaveBeenCalledTimes(1);
	  expect(setError).toHaveBeenCalledWith(null);

	  // Test that setIsLoading was called twice, with
	  // true the first time and false the second time:
	  expect(setIsLoading).toHaveBeenCalledTimes(2);
	  expect(setIsLoading.mock.calls[0][0]).toBe(true);
	  expect(setIsLoading.mock.calls[1][0]).toBe(false);
	});

	test('The function returned by getDataEffect behaves as expected when making an unsuccessful request', async () => {
	  const url = 'https://fake.url';
	  const error = new Error(message);

	  // Mock the async getJson function to reject with the error:
	  const getJson = jest.fn();
	  getJson.mockReturnValue(Promise.reject(error));

	  // Mock the setter functions:
	  const setData = jest.fn();
	  const setError = jest.fn();
	  const setIsLoading = jest.fn();

	  // Run the effect:
	  const func = getDataEffect({ url, getJson, setData, setError, setIsLoading });
	  await func();

	  // Test that getJson was called once with the provided url:
	  expect(getJson).toHaveBeenCalledTimes(1);
	  expect(getJson).toHaveBeenCalledWith(url);

	  // Test that setData was not called:
	  expect(setData).not.toHaveBeenCalled();

	  // Test that setError was called once with the error:
	  expect(setError).toHaveBeenCalledTimes(1);
	  expect(setError).toHaveBeenCalledWith(error);

	  // Test that setIsLoading was called twice, with
	  // true the first time and false the second time:
	  expect(setIsLoading).toHaveBeenCalledTimes(2);
	  expect(setIsLoading.mock.calls[0][0]).toBe(true);
	  expect(setIsLoading.mock.calls[1][0]).toBe(false);
	});

The first test just validates that `getDataEffect` returns a function. It's the same basic sanity check we've used in all the other examples. The second test validates the entire flow for a successful request:

1. We define a fake request run and data.
2. We create a mock function for `getJson` that returns a promise, which will resolve with the expected data.
3. We create simple mock functions for the state setters.
4. We call `getDataEffect` to obtain the async function.
5. We call the function and wait for it to return.
6. We test that `getJson` was called once with the provided url.
7. We test that `setData` was called once with the expected data.
8. We test that `setError` was called once with `null`.
9. We test that `setIsLoading` was called twice, with `true` the first time and `false` the second time.

The third test validates the entire flow for an unsuccessful (error) request. It's similar to the second test but the expectations are different. The mock `getJson` function returns a promise, which will reject with an error. `setError` should be called with that error. `setData` should not be called.

## Wrapping Up

We now have a consistent structure that keeps business logic out of our components and makes our code easier to read. We're also able to write comprehensive tests to validate that our code does the right thing, which can improve confidence in the codebase. (This assumes that you actually run your tests regularly and integrate them into your continuous integration pipeline, but that's a topic for another post.) This is one of many ways to structure your components. I hope it gives you some ideas to establish an architecture that suits your needs.