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.
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.
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.
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.
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.
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:
The behaviour of addToSumEffect should be stable and predictable for any given values of sum and addNumber. We can confirm this with tests.
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.
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.
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.
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.
Let’s break the new additions down step by step:
We also have new tests to confirm that initialize SumEffect returns a function, and that the returned function calls setSum with the expected value.
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.
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. Loading Indicator 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:
Next, let’s add tests for getDataEffect:
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:
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.
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.
What happens if you wait too long to replace old technology?