
Leaner React - Part 2 - State Management
State management in React is crucial for building complex applications. It can escalate very quickly, and it’s important to keep it under control. In this article, we’ll explore some ways to improve managing state in React.
Disclaimer: these are not hard rules, but rather recommendations that usually lead to cleaner code. There are exceptions that justify not following them.
TLDR:
- Avoid React states, prefer alternatives
- Avoid controlled inputs, prefer using the state of the element itself
- Avoid managing state of interactive elements, prefer native states
- Avoid managing styling via JavaScript, prefer CSS
- Avoid validation via JavaScript, prefer browser-native validation
- Avoid useState/useReducer/useContext for shared states, prefer extracting your state from your components
- Avoid coupling rendering to state, prefer decoupled rendering/state management
- Avoid higher-order functions, prefer normal functions
The deep state
When it comes to managing state in React, most often we reach for the useState
hook. It seems easy and straight-forward. And that is true, when you have a small component and only need maybe one or two states. But the bigger the application gets, the faster it becomes a mess. State declarations need to be lifted up, states need to be prop-drilled through multiple layers of components (without them ever needing them), and the whole application becomes bloated and hard to read. Using a context improves readability, but it re-renders the whole components tree inside the provider, which hurts performance. How can we improve all this?
Avoid React states, prefer alternatives
In many situations, you can avoid managing states via React states. Many interactive elements have their own state. But how can we access or utilize them?
Avoid controlled inputs, prefer using the state of the element itself
Typically, if we need to get the value of an input, we would use a controlled input:
Controlled input
import { useState, ChangeEvent } from "react";
export const TextInput = () => {
const [value, setValue] = useState("");
const handleInput = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value;
setValue(value);
doSomethingWith(value);
};
return (
<input type="text" value={value} onInput={handleInput} />;
);
};
While controlled inputs make sense in some cases (e.g. you need to fetch data on every key-stroke for auto-complete suggestions), most of the times they are not the best solution for your problem. They will re-render the component on every key-stroke, and the more computation has to be done, the slower and laggier your application will feel. If you need only the value of the input as part of a form submission, you can access it in the form’s submit-event:
Form submit event
import type { FormEvent } from "react";
export const Form = () => {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
const value = event.currentTarget["user-input"].value;
doSomethingWith(value);
};
return (
<form onSubmit={handleSubmit}>
<input name="user-input" type="text" />;
</form>
);
};
Avoid managing state of interactive elements, prefer native states
Many interactive elements have their own state. Most often you don’t need to re-invent the wheel for what you’re trying to achieve. For example, if you need a collapsible, you can use the <details>
element. If you need a modal/dialog you can use <dialog>
. There is a wide range of available elements that most often will fit your needs (<video>
, <audio>
, track
etc.) without having to implement state management.
React State Collapsible
import { useState } from "react";
export const Collapsible = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>
Summary
</button>
{isOpen && <p>Details</p>}
</>
);
};
Details/Summary
export const Collapsible = () => {
return (
<details>
<summary>Summary</summary>
<p>Details</p>
</details>
);
};
Avoid managing styling via JavaScript, prefer CSS
Using JavaScript over CSS to style elements will strongly slow your application down. The browser can handle HTML/CSS rendering much faster than JavaScript. There are plenty of pseudo-selectors that can help you style elements based on their state: :hover
, :focus
, :active
, :disabled
, :checked
etc. See the following examples:
JavaScript Event Listeners
import { useState } from "react";
export const StyledButton = () => {
const [hasHover, setHasHover] = useState(false);
const [hasFocus, setHasFocus] = useState(false);
return (
<button
onMouseEnter={() => setHasHover(true)}
onMouseLeave={() => setHasHover(false)}
onFocus={() => setHasFocus(true)}
onBlur={() => setHasFocus(false)}
className={`
${hasHover ? "bg-blue-500" : "bg-transparent"}
${hasFocus ? "border-blue-500" : "border-black"}
${hasHover ? "text-white" : "text-black"}
`}
>
Action
</button>
);
};
Pseudo-selectors states
export const StyledButton = () => {
return (
<button
className={`
bg-transparent text-black border-black
hover:bg-blue-500 hover:text-white focus:border-blue
`}
>
Action
</button>
);
};
Avoid validation via JavaScript, prefer browser-native validation
The previous recommendation can be applied for validation and styling based on validation state as well. There is a wide range of native input validation attributes that solve most of the use-cases: required
, minLength
/maxLength
, min
/max
, type
(e.g. email
), pattern
(for custom regexes) and so on (see mdn web docs for a deep dive). For styling based on validation state, you can use the :valid
or :invalid
pseudo-selectors.
JavaScript validation
import { FormEvent, useState } from "react";
export default function Form() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const isValid = () => {
const _errors: Record<string, string> = {};
if (!email) {
_errors.email = "Email is required";
} else if (!/^[^s@]+@[^s@]+.[^s@]+$/.test(email)) {
_errors.email = "Invalid email format";
}
if (!password) {
_errors.password = "Password is required";
} else if (password.length < 6) {
_errors.password = "Password must be at least 6 characters long";
}
setErrors(errors);
return Object.keys(_errors).length === 0;
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
if (isValid()) {
// submit ...
}
};
return (
<form onSubmit={handleSubmit}>
<label>Email</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && <p>{errors.email}</p>}
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{errors.password && <p>{errors.password}</p>}
<button type="submit">Submit</button>
</form>
);
}
Native validation
import type { FormEvent } from "react";
export default function Form() {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
// submit ...
};
return (
<form onSubmit={handleSubmit}>
<label>Email</label>
<input type="email" required />
<label>Password</label>
<input type="password" required minLength={6} />
<button type="submit">Submit</button>
</form>
);
}
You might say: “But I need to validate the input on each key-stroke!”. To this end you can use the checkValidity()
function: mdn web docs
Avoid useState/useReducer/useContext for shared states, prefer extracting your state from your components
When you have a state that is used in multiple components, the most common recommendation is to lift it up to a common ancestor. There is no need for that. In my experience, it usually ends up in more complex code. Avoid reducers (and Redux), it is an abomination. Use plain functions, extract your states into an external store (e.g. Zustand) and inject them in the components that need it, similar to dependency injections. See one of my previous articles for more details: React State Management.
Avoid coupling rendering to state, prefer decoupled rendering/state management
I like to see components as templates. Simple, pure functions that use data (or not) and return html. They should not have side-effects. If you need data (from a server or a state), handle that outside of the component, and inject it. Same goes for functions that change the state. Define them outside of the component and inject them.
Avoid higher-order functions, prefer normal functions
Have you ever seen a hook that takes an argument and returns a function, but internally the returned function accesses the argument via the lexical scope? For example like this:
Higher-order function
export function useLog(value: string) {
const log = () => {
console.log(value);
};
return { log };
}
This is a often seen pattern, that is very dangerous in my opinion and does not simplify anything. It is particularly dangerous when it is used with states: the function might be called with a stale value, making it hell to debug. Instead, prefer normal functions, that take the value as an argument and that ideally don’t get recreated on every render.
Normal function
export function log(value: string) {
console.log(value)
}
Conclusion
That’s it for part 2! Applying these recommendations will significantly simplify handling state management. In the next part, we’ll look into effects.