React became the most popular frontend library partly due to its simplicity and gentle learning curve. You learn to make components, pass props to them, conditionally render, a few hooks, and that’s it, you are ready to make React applications. But what about some advanced principles and capabilities? Those are some built-in React hooks and components that are not necessary for simple applications, but can prove to be truly handy when optimization comes to the table on more complex applications.
Our team adheres to the principles of React, recognizing their immense value in building exceptional applications.
React Hooks and Best Practices
As a warm-up, let’s start with a hook that everyone who has built a React application must have used:
useState
Let's you add a state variable to your component.
const Count = () => { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); }; return <button onClick={increment}>count is {count}</button>; };
Here we have a Count component that renders one button, and when the user clicks on it, it will increment the counter and display a new number, a basic component that everyone has once made. Of course, the useState hook is used for storing a variable. But this is not the correct way to do this! If you need to use the previous state to update the next one, do it this way:
const Count = () => { const [count, setCount] = useState(0); const increment = () => { setCount((prev) => prev + 1); }; return <button onClick={increment}>count is {count}</button>; };
Because changing state value happens asynchronously, you need to be sure that you are using the correct previous state in case some other function changes it in the meantime.
So you pass a function to setState that takes one parameter (previous state), does your calculation, and returns the next state.
Handling multiple states
The next example is something everyone has experienced - handling multiple states for input fields:
const Form = () => { const [name, setName] = useState(''); const [lastName, setLastName] = useState(''); const [username, setUsername] = useState(''); const changeName = (e: React.ChangeEvent<HTMLInputElement>) => { setName(e.target.value); }; const changeLastName = (e: React.ChangeEvent<HTMLInputElement>) => { setLastName(e.target.value); }; const changeUsername = (e: React.ChangeEvent<HTMLInputElement>) => { setUsername(e.target.value); }; return ( <> <input id="name" value={name} onChange={changeName} /> <input id="lastname" value={lastName} onChange={changeLastName} /> <input id="username" value={username} onChange={changeUsername} /> </> ); };
This code works, you have three inputs, three states for every one of them, and then three functions for changing those states. In short, we have code duplication that does the same thing for every state.
The solution for shorter and simpler code is this:
const Form = () => { const [values, setValues] = useState({ name: '', lastName: '', username: '', }); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { setValues((prev) => ({ ...prev, [e.target.id]: e.target.value })); }; return ( <> <input id="name" value={values.name} onChange={handleChange} /> <input id="lastname" value={values.lastName} onChange={handleChange} /> <input id="username" value={values.username} onChange={handleChange} /> </> ); };
We are using one state that has an object as a value, the properties of that object are values of input fields. Note that property names are the same as input ids! And we have one handle change function that accepts the change event parameter and uses it to handle value change like this:
It calls setValues and passes the function to it with the previous value (that we learned in the previous example). Then we make a new object, destructure it and use the id of a change event (id of an input element) to set the input value to the object property of that name.
That’s it! We now have a simpler and more readable code.
useRef
The useRef hook lets you reference a value that’s not needed for rendering.
const dayOffToDeleteId = useRef<string>(''); const openDeleteModal = (id: string) => { dayOffToDeleteId.current = id; setOpenYesNoModal(true); };
In this example, we are referencing the id of some day-off entity and using it later when we need it. useRef is used here because we don’t need to trigger re-render when we change that value (The look of the application doesn’t depend on it)
Referencing HTML elements
const Focus = () => { const inputRef = useRef<HTMLInputElement>(null); const handleClick = () => { if (inputRef.current) inputRef.current.focus(); }; return ( <div> <input ref={inputRef} type="text" /> <button onClick={handleClick}>Focus Input</button> </div> ); };
This is yet another usage of useRef. You can reference html elements using the ref attribute and then use it later to trigger some methods of that element, change some attributes, etc.
In this example, we use it to focus on input by clicking on a button.
useEffect
Lets you perform side effects.
useEffect(() => { console.log("Component has mounted."); return () => { console.log("Component will unmount."); }; }, []);
Hook that I hope everyone is familiar with, if dependencies are empty, it will run on component mount, and if we return a function from it it will run it on unmount.
useEffect(() => { console.log(count); }, [count]);
And also if we set some state as a dependency it will trigger useEffect when it changes.
But what you can put as a dependency is a little bit tricky! Because of the way react compares it.
In the background, it uses Object.is() a JavaScript method to compare its values. So if you are using primitive types as dependencies everything will be fine. But problems come when you want to use reference types.
Primitive types:
- boolean
- null
- undefined
- number
- bitInt
- string
- Symbol
Reference types:
- Object
- Array
- Function
Note that Arrays and Functions are also objects, but I mentioned them also for better understanding.
So when you put an object as a dependent variable, it will compare its reference and not the value itself. Reference points to the location of the object in memory, and that is changed every time a render occurs. And that’s not what we want!
So if you need to use an object as a dependent variable, there are solutions:
Use one of the object's primitive types:
useEffect(() => { console.log('Test'); }, [user.name]);
Here name property is a string (primitive type) so it will work as desired.
If depending on one or more primitive properties doesn't cover your use case, the best approach is to restructure your state so that you depend on primitives directly. As a last resort, libraries like use-deep-compare-effect exist for deep comparison, but reaching for them is usually a sign that state structure should be reconsidered first.
useMemo
Let's you cache the result of a calculation between re-renders.
const background = useMemo(() => { const daysRatio = availableDays / total; const degree = daysRatio * 360; return `conic-gradient(${secondaryColor} ${360 - degree}deg, ${mainColor} ${360 - degree}deg 360deg)`; }, [availableDays, total, secondaryColor, mainColor]);
This is one of the hooks used for optimization. In the example above, we have some “expensive” function that calculates the background; it has an array of dependencies, meaning that it will recalculate only when some of the listed dependencies are changed. And the value of a background constant is cached between re-renders if the values of dependencies are unchanged.
If we create this function without useMemo, it will be executed on every re-render, which is a performance risk if the function takes too long to execute, and especially if the component rerenders often
However, not everything should be cached with useMemo; you don’t need to overwhelm the memory, as some functions are okay to be executed every time.
useCallback
Let's you cache a function definition between re-renders.
It’s similar to useMemo, but the difference is that useMemo only caches a return value of some function, whereas useCallback caches the whole function. What is the usage of that? You may ask, let’s see the next example:
export default function ProductPage({ productId }) { const handleSubmit = useCallback( (orderDetails) => { post('/product/' + productId + '/buy', { orderDetails, }); }, [productId] ); return ( <div> <ShippingForm onSubmit={handleSubmit} /> </div> ); }
In this example, if the ShippingForm component is re-rendered because of some state changes inside of it, the handleSubmit function will be recreated. That can slow down the application if the function is “expensive”. So using the useCallback, the function will be re-created only if the dependency (productId in this case) is changed.
useTransition
Let's you update the state without blocking the UI. Both useTransition and useDeferredValue were introduced in React 18 as part of Concurrent Mode, a React feature that allows rendering to be interrupted and prioritized. Without Concurrent Mode, all state updates were treated equally. These hooks let you mark certain updates as non-urgent, so React can keep the UI responsive while they process in the background.
const [isPending, startTransition] = useTransition(); const [tab, setTab] = useState('about'); function selectTab(nextTab: string) { startTransition(() => { setTab(nextTab); }); }
Here we have a logic for changing tabs, while the selected tab is stored in the state. One of the tabs is slow; it takes some time to load. And if this example were without the useTransition, if a user clicks on a slow tab and then decides to switch before it loads, he would need to wait for it to finish loading. But if you use the useTransition hook, it will not block the UI, and the user can immediately switch tabs. It also provides an isPending state that can be used for displaying the loader or something similar.
useDefferedValue
Let's you defer updating a part of the UI.
export default function App() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); return ( <> <label> Search albums: <input value={query} onChange={e => setQuery(e.target.value)} /> </label> <SearchResults query={deferredQuery} /> </> ); }
If a state changes the component appearance of the component. In this case, the display of searched results. You can use the useDefferedValue hook to keep the previous value on display until a new one is loaded.
The syntax is simple: just pass the state that triggers re-render to the hook on initialization. And then use the value that the hook returns as it would otherwise be used.
Built-in React Components
<Suspense />
Let's you display a fallback until its children have finished loading.
export default function App() { const [query, setQuery] = useState(''); return ( <> <label> Search albums: <input value={query} onChange={e => setQuery(e.target.value)} /> </label> <Suspense fallback={<h2>Loading...</h2>}> <SearchResults query={query} /> </Suspense> </> ); }
Suspense is one of React's built-in components; it has similar usage as the useDeferredValue hook. But instead of keeping the previous state on display, it shows the fallback component while the component is being rendered. Fallback is usually some form of a loader.
You just wrap your component with Suspense and pass your loading component as a fallback prop.
The example is the same as for useDefferedValue, so the behavior will look like this:
Built-in React APIs
lazy()
Lets you defer loading the component’s code until it is rendered for the first time.
const CalendarView = lazy(() => import("./views/Calendar"));
lazy() is a built-in function that lets you improve the performance of your application by loading a component only when it needs to be rendered for the first time. It comes in useful in the App.tsx file, where you usually import all your pages and pass them to the router. So a lazy function will prevent the page component from loading until the user visits it.
memo()
Let's you skip re-rendering a component when its props are unchanged.
const SomeComponent = memo(function SomeComponent(props) { // ... });
In React, if the parent component re-renders, it will also re-render all its child components. But if some of your child's components are heavy, you don’t want to re-render them every time. That’s when you use the memo() function that will re-render the component that you pass to it only when its props have been changed.
KEEP IN MIND that if you are passing a function to the child component, memo will not work because when the parent re-renders, it will re-initialize the function - thus changing its reference. That's when you need to use the useCallback hook for caching a function between re-renders.
const Parent = () => { const [count, setCount] = useState(0); const handleClick = useCallback(() => { console.log('clicked'); }, []); return ( <> <button onClick={() => setCount((prev) => prev + 1)}>Re-render parent</button> <HeavyChild onClick={handleClick} /> </> ); }; const HeavyChild = memo(function HeavyChild({ onClick }) { console.log('HeavyChild rendered'); return <button onClick={onClick}>Click me</button>; });
Without useCallback, HeavyChild would re-render on every parent re-render, despite being wrapped in memo, because handleClick would be a new function reference each time.
Conclusion
Those were some hooks, components, and functions that are included in the React library. Not all of them are covered in this article, and a few are left, but I find this one most useful for building a good React application. Applications can be built only using useState and useEffect, but for advancing in React, feel free to integrate this other aspect of development.
You can find more documentation about React hooks on their documentation. See you on our blog on the next topic.
