This is the third article in a series covering different strategies to simplify your React applications.
Simplify a React component
There's a number of strategies that we can take to simplify our components, without drastically overhauling our code. Each strategy will be covered in a different post.
- Separate state from display, this will help your application align with well established MVC rules
- Defer processing to services and custom hooks
- Avoid overloading
useEffect
anduseState
- Determine if
redux
&redux-saga
are really needed - Create higher order components to join functionality between components
- Shift computational logic out of components into helper functions, inject with custom hooks
- Use lazy loading and lazy behaviour where possible
Avoid overloading useEffect
and useState
useEffect
and useState
are powerful tools in the React functional arsenal. The useState
hook supports binding persistent state to a component through multiple renders, while useEffect
is similar to the componentDidMount
and componentDidUpdate
lifecycle methods of React class components, except that the function will execute once the render has been committed to the screen.
The useState
hook, how and when to use it
The useState
hook provides support for setting stateful data on a component, and when a new value is set, equates to a re-render of the component. This is especially valuable for components that need to maintain localised state, specific to the component, or to be passed to children components as a prop.
One particular usage of useState
is to set transitional states for a component, that could be driven by the fetching and rendering of asynchronous data. When loading data, we should be presenting a temporary state to the user, and transitioning from that previously rendered state to the new state.
We can also capture user input within the component, and trigger re-renders of the component and not the parent, by avoiding prop drilling and using local state:
// Example from React
function Counter({initialCount}) {
const [count, setCount] = useState(initialCount);
return (
<>
Count: {count}
<button onClick={() => setCount(initialCount)}>Reset</button>
<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
</>
);
}
React's unidirectional update of changes means that we can avoid issues where prop changes are communicated from child to parent, like it was possible in earlier versions of AngularJS. While it's a good thing, maintaining state across multiple components in the same parent component, especially where transitional data and error messaging is relevant, can be a tricky situation.
One such example is the fetching of data from an API, the transformation of that data, and the handling of various error scenarios. Depending on the way that error handling is presented, especially when using static pages and static routes, it may not be possible to customise the data that's presented to the user.
const [loaded, setLoaded] = useState(false);
const [hasTransformError, setHasTransformError] = useState(false);
const [hasApiFetchError, setHasApiFetchError] = useState(false);
const [hasSomeOtherError, setHasSomeOtherError] = useState(false);
useEffect(async () => {
try {
const response = await fetch("/some/api");
const json = await response.json();
const transformed = transformer.transformJson(json);
} catch (e) {
if (e instanceof TransformerError) {
setHasTransformError(true);
} else if (e instanceof ApiError) {
setHasApiFetchError(true);
} else {
setHasSomeOtherError(true);
}
}
});
if (hasTransformerError || hasApiFetchError || hasSomeOtherError)
// Possibly render error to screen, or redirect to hard fail/static error screens
While the above pattern is an example, it's not a graceful nor elegant way of handling error scenarios, but for specific circumstances, such as the fetching data from one api endpoint on page load, fetching data from another api endpoint to verify, and posting data to another endpoint, the methods of handling various error scenarios can be limited when using React components.
Setting useState with initial data from callable function
You can initialise an instance of the useState
hook with an object or primitive data, or a callable function that is executed by React, and the value is returned as the default value. This can be useful in circumstances where an initial value may need to be calculated from a data store, and it's cleaner than prop drilling.
It's worth keeping mind, that any value derived from a computationally intensive function, provided as the default callable to useState
will block the UI from rendering, and this is why it's always advised to rely on useEffect
to provide lazy loading of data. Unlike a callable function, useEffect
will not block the UI after render.
Lazy loading state with useEffect
useEffect
when combined with useState
is a powerful asynchronous tool for loading, mutating and displaying data provided by an API. It's a very common strategy employed in many React applications, and is one of the common reasons for creating custom hooks.
With our previous example, we create a component called Todos
, which fetches and displays content from the json placeholder API. This component is responsible for quite a lot - fetching data from an API, transformation, reconciling state and rendering the UI.
const Todos = () => {
const [todos, setTodos] = useState();
useEffect(async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/');
const todos = await response.json();
setTodos(todos);
}, []);
// Render the content of the screen
}
We can shift a lot of the processing and state handling to a custom hook, and expose the values returned by the custom hook, such as todos
:
const useTodos = () => {
const [todos, setTodos] = React.useState([]);
React.useEffect(async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/');
const todos = await response.json();
setTimeout(() => {
setTodos(todos);
}, 2500);
}, []);
return { todos };
}
const Todos: React.FC = () => {
const { todos } = useTodos();
return todos.length > 0 ? <p>Hello, world! {todos.length} todos!</p> : <Spinner/>
}
Observable updates with useState
and useEffect
useEffect
can be initialised with an observable array of properties, causing the useEffect
to be executed each time one of the properties is changed. This is especially useful when listening to UI events across the page, and rendering components as the user interacts with various elements on the screen.
A very common use case is pagination. Given a user interacts with a button, we want to show another set of records as we traverse the data set.
When we run thee codepen, we see three things:
- We've used a custom hook
useTodos
to encapsulate our API fetching, data transformation and temporary caching of Todos - A
useEffect
hook call binds topage
- We export two derived values and
setPage
By exporting setPage
, we can very easily trigger UI updates and additional data fetching by setting a new value with setPage
:
const TodosPaginate = () => {
const {todos, page, setPage} = useTodos();
return (
<div>
<p>Page: {page}</p>
{ page > 1 ? <button onClick={() => setPage(page-1)}>Prev ({page-1})</button> : null }
{ page < 10 ? <button onClick={() => setPage(page+1)}>Next ({page+1})</button> : null }
</div>
);
}
Custom hooks
Custom hooks are great ways to encapsulate behaviour. The code can be trivial, or quite complex and intensive, but importantly it's encapsulated and removed away from the view layer of our components. This is a common design trait with MVC applications, in this case we're treating our custom hooks as models, defining logic and behaviours that directly affect the view. That model can also respond to user inputs, through exposed helper functions or setState
calls.
It's important to remember though, that custom hooks should not maintain state that can be consumed across multiple components. This is so that the application behaves in a predictable and reliable fashion, and that hooks aren't abused and used in ways that they were never designed to.
In our example above, we've exposed the todos
list, the page
number and setPage
, which will trigger the asynchronous loading and re-render of the page. In our codepen example, we've also attempted to load the same custom hook useTodos
into both the Todos
and TodosPaginate
component.
const Todos = () => {
const {todos, page} = useTodos();
// ...
}
const TodosPaginate = () => {
const {todos, page, setPage} = useTodos();
// ...
}
An expected behaviour here might be to have the same hooks shared across multiple components. That would be handy and great, wouldn't it? But alas, the real behaviour here is that the hooks are merged with the component that's calling them. Given n
number of components using useTodos
, there will be n
number of bindings of the same useState
and useEffect
calls. It's trivial to test this - add console.log
or view the network tab in your console to try it out yourself.
The proper React way to expose data for use across multiple components and custom hooks is to use the Context API.
Context API
The context api provides a way to pass data through the React component tree without resorting to prop drilling. You can implement it as much as or little as you like, and you can implement it in specific locations.
The data contained in the Context API is considered to be global data, and can be used throughout your application. Data that is considered priviledged, such as authenticated user information, or a secure cookie perhaps, should not be stored in a context. The Context API is great for use with theme overrides, specific localised behaviour such as pagination, or controlling page layout.
We can take our simple Todos app and make it more responsive to page navigation, by declaring a Todos Context and providing it to our components. There is one caveat - a context will only publish changes when the source data changes. This ensures unidirectional communication and propagation. We can define a handler to update the state for us, and provide it through the context Provider
.
It's worth noting that it's not required to provide a context Consumer in functional components, because we can use a custom hook instead. A Consumer
and custom hook behave similarly - an object is provided and will listen to propagated updates. With the custom hook you can expose data from the useContext
, while the Consumer
requires a function to render something to screen with the variables provided by the context.
<TodosContext.Consumer>
{todos => /* render something based on the context value */}
</TodosContext.Consumer>
// Behaves similarly to:
const useTodosContext = () => {
return { page, todos } = React.useContext(TodosContext);
}
When to consume useEffect
and when to consume useLayoutEffect
The useEffect hook is a powerful feature of React that allows functional components to behave in an asynchronous way. If you're not careful, there are traps that you need to avoid that are made as clear as they could possibly be. It is very easy to trigger multiple executions of a useEffect
hook if you're not careful, and before you know it, your application will be grinding your browser to a halt.
It's not always necessary to execute useEffect
after each re-render, and there are ways to mitigate against this, using useState
, useRef
, or observing values that don't change. The best way to apply these methods is to use a custom hook, but each of these strategies still execute useEffect
multiple times.
// Using `useState` to maintain execution state for hook
const useCustomHook = (fn) => {
const [state, setState] = useState({completed: false});
useEffect(() => {
// Only execute if state.completed has not been set yet
if (!state.completed) {
fn && fn();
setState({...state, completed: true});
}
}, [state.completed]);
}
// Using `useRef` to maintain execution state for hook
const useCustomHook = (fn) => {
const ref = useRef(false);
useEffect(() => {
// Only execute if ref.current is true
if (!!ref.current) {
fn && fn();
} else {
ref.current = true;
}
}, [ref.current]);
}
// Only execute this hook once, ever, but this _will_ throw an exhaustive deps warning with eslint!
const useCustomHook = (fn) => {
useEffect(() => {
fn && fn();
}, []);
}
Having the ability to observe on a changing property is valuable for responding to specifically observable events, such as pagination as previously described, or incorporating RxJS into your application.
While you'll likely use useEffect
in almost all occasions for loading data asynchronously and even mutating the DOM, useLayoutEffect is fired immediately after the DOM has been updated. This is before the browser "paints" the changes, providing an entry point to do additional mutations before the user can even see the changes. This is hugely beneficial when content needs to be dynamically resized, or external DOM documents are being loaded and need to be mutated, or styles need to be changed.
Because the hook fires synchronously, computationally intensive functions will block the render of the UI, resulting in an interface that may appear laggy or glitchy. You should use useLayoutEffect
when you need to mutate the DOM and/or perform/calculate measurements, and useEffect
when you don't need to directly interact with the DOM, or mutations are asynchronous/observable.
Stay tuned for the next article when we determine if redux
and redux-saga
are really needed, and what other options are available.
Top comments (0)