28
loading...
This website collects cookies to deliver better user experience
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;
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.// 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;
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.useEffect
hook and other event handlers, which we'll see later on.addToSumEffect
. This cleans up the component nicely and allows us to write more comprehensive tests.// 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;
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));
};
};
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.addToSumEffect
is called with a hash of the current addNumber
, sum
and setSum
values from the componentaddToSumEffect
returns a new function with the current addNumber
, sum
and setSum
values in scopeonClick
propsum
and addNumber
valuessetSum
which updates the sum on the component's statesum
addToSumEffect
should be stable and predictable for any given values of sum
and addNumber
. We can confirm this with tests.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');
});
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);
});
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.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.// 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;
initialNumber
propinitializeSumEffect
is called with a hash of the current initialNumber
and setSum
values from the componentinitializeSumEffect
returns a new function with the current initialNumber
and setSum
values in scopeuseEffect
hook (note that the hook is configured to run only when initialNumber
has changed, not on every render)useEffect
runs, calling the returned functioninitialNumber
value is passed to setSum
which updates the sum on the component's stateinitializeSumEffect
returns a function, and that the returned function calls setSum
with the expected value.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.// 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;
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: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.useEffect
hook calls the async function that was returned by getDataEffect
.true
, which causes the loading indicator to render.getJson
with the request url and waits for a response.null
and the loading state to false
. The component stops rendering the loading indicator and passes the data to DataView
to be rendered.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.getDataEffect
:// 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);
});
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:getJson
that returns a promise, which will resolve with the expected data.getDataEffect
to obtain the async function.getJson
was called once with the provided url.setData
was called once with the expected data.setError
was called once with null
.setIsLoading
was called twice, with true
the first time and false
the second time.getJson
function returns a promise, which will reject with an error. setError
should be called with that error. setData
should not be called.