State: Separating logic and render
As you scale your application, you will inevitably need two parts of your app, which are not near each other visually or in the DOM, need to access the same data; for example, consider a code editor with a file tree on the left. Opening a file needs to update the main window and also the file tree (to highlight the file).
The common response to this is to "elevate" (opens in a new tab) the state to the nearest shared ancestor, and thread the state, and its setter, down to both components via props.
The default: prop drilling
Do this enough, and you'll find your codebase looking like this:
<MyComponent
currentlyOpenFile={...}
setCurrentlyOpenFile={...}
currentWorkingDirectory={...}
setCurrentWorkingDirectory={...}
tabsOrSpaces={...}
setTabsOrSpaces={...}
zoomLevel={...}
setZoomLevel={...}
{...evenMoreProps}
/>
This quickly becomes hard to manage, and slows down development of your application. Every piece of application-level state now needs to be created at the top and threaded through N-many levels of your tree.
This is called prop drilling (opens in a new tab), and is the sign that your application has out-grown basic state management.
Event delegation
As a word of caution, many people find themselves prop drilling because they forget that React renders to HTML, and don't leverage the DOM. For example, imagine you have an email inbox with each email as a row, and every row has a button to select the row (for bulk operations) and one to delete the row. At first, you might imagine that you need to pass props to select and delete to each row.
<Email
id={...}
subject={...}
select={...}
delete={...}
/>
However, this doesn't leverage the fact that in HTML all events traverse the entire tree.
Instead of handling the onClick
event inside of each row, we can instead handle it at the table level by attaching a data-id
attribute to every email.
function Email({ id, subject }) {
return <div>
{subject}
<button data-id={id} data-action="SELECT" />
<button data-id={id} data-action="DELETE" />
</div>;
}
And extracting it from the "target" of the event (the inner most element):
<Inbox onClick={(ev) => {
const id = ev.target['data-id'];
const action = ev.target['data-action'];
...
}}>
{...emails}
</Inbox>
Architecture
That said, not all prop drilling can be solved with event delegation (and sometimes event delegation can make your code messier or create spooky action at a distance). Another way to reduce prop drilling is to rearchitect your application to construct the components that need state closer in the tree to where that state is defined.
Composition
Imagine we're building an app, and in the top right there is a dropdown that lets the user:
- Switch accounts
- Go to the settings page
- Sign out
The state of which accounts the user can switch to, the current page, and sign in status are all stored at the root. One way, and probably the default way, to build this is to drill all of the props:
function App() {
return <>
<Header
allAccounts={[...]}
switchAccounts={(newAccount) => { ... }}
switchPages={(newPage) => { ... }}
signOut={() => { ... }}
/>
{...}
</>
}
function Header(props) {
return <>
<Dropdown
allAccounts={props.allAccounts}
switchAccounts={props.switchAccounts}
switchPages={props.switchPages}
signOut={props.signOut}
/>
{...}
</>
}
function Dropdown(props) {
return <>
<SwitchAccountsItem
allAccounts={props.allAccounts}
switchAccounts={props.switchAccounts}
/>
<GoToSettingsItem switchPages={props.switchPages} />
<SignOutItem signOut={props.signOut} />
</>
}
However, this makes the code brittle to changes, as any new prop will now need to be added to multiple layers of components; another option is to pass the children elements into the Dropdown
:
function Header(props) {
return <>
<Dropdown>
<SwitchAccountsItem
allAccounts={props.allAccounts}
switchAccounts={props.switchAccounts}
/>
<GoToSettingsItem switchPages={props.switchPages} />
<SignOutItem signOut={props.signOut} />
</Dropdown>
{...}
</>
}
function Dropdown(props) {
return <>
{props.children}
</>
}
And this pattern can extend to the header:
function App(props) {
return <>
<Header dropdown={
<Dropdown>
<SwitchAccountsItem
allAccounts={[...]}
switchAccounts={(newAccount) => { ... }}
/>
<GoToSettingsItem switchPages={(newPage) => { ... }} />
<SignOutItem signOut={() => { ... }} />
</Dropdown>
} />
</>
}
function Header(props) {
return <>
{props.dropdown}
</>
}
function Dropdown(props) {
return <>
{props.children}
</>
}
The advantage of this is that if the account switcher needs new props, or you want to add another item to the dropdown, there is no need to drill props. This pattern is called composition over inheritance (opens in a new tab), and is recommended by the React docs themselves.
Render props
Composition works great, but it breaks down when you have a component that needs props from multiple layers in the component tree. For example, consider the same dropdown case, but you want to close the dropdown menu if the user changes accounts--well now we have a problem: the state of whether the dropdown is opened is handled within the <Dropdown>
component, but all the other state is at the root.
We can solve this with a render prop (opens in a new tab): rather than passing children, pass a function that returns children:
function App(props) {
return <>
<Header dropdown={
<Dropdown items={(closeDropdown) => {
return <>
<SwitchAccountsItem
allAccounts={[...]}
switchAccounts={(newAccount) => { ... }}
closeDropdown={closeDropdown}
/>
<GoToSettingsItem switchPages={(newPage) => { ... }} />
<SignOutItem signOut={() => { ... }} />
</>;
}} />
} />
</>
}
function Dropdown(props) {
return <>
{props.dropdownItems(() => { ... })}
</>
}
Context
If neither of those are possible, React does have a way to pass data to arbitrarily deep descendents: context (opens in a new tab).
Context is defined globally (with a default):
import { createContext } from 'react';
export const TotalTodosContext = createContext(0);
Set somewhere in the tree:
function App() {
const [todos, setTodos] = useState([]);
return <TotalTodosContext.Provider value={todos.length}>
{...}
</TotalTodosContext>;
}
And can be read in any descendent:
function DeleteAllTodosButton() {
const totalTodos = useContext(TotalTodosContext);
return <button>
Delete {totalTodos} todos
</button>;
}
If a component tries to read context, and no ancestor set it, the context call will get back the default.
An implicit dependency
While context seems magical, I would strongly caution against using it for any value that's not truly global in your app. The reason is that it's an implicit, and unmarked, dependency: imagine a function that before you call it you had to set a specific global variable... if that's not somehow marked on the function, it's likely to be forgotten as that code is refactored or reused. The same is true of context.
Imagine you're developing an online shopping website:
- You've written the shopping cart component that has an item component that takes in a product name, quantity, and price and displays it to the user.
- Later on, someone decides that each row in the shopping cart should allow users to right click to remove the item from the shopping cart. Not wanting to drill another method, you decide that each item should reach into a shopping cart context to remove itself from the cart.
- Sometime later, a different developer is working on the past orders page, likes the display of the "list of items" component from the shopping cart, and reuses it (unaware of the remove from cart behavior).
Unfortunately, because neither the ShoppingCartListOfItems component or the ShoppingCartItem component obviously depended on the shopping cart context, when a user right clicks to remove an item not in the shopping cart, at best it will do nothing and at worst it will crash. Had those components declared their dependencies, this wouldn't have happened.
Now one could argue that the developer should have fully audited the component before they chose to reuse it, but that becomes prohibitively expensive as the app scales. Imagine you're making a food ordering app, and there is a map view to see restaurants, and when you click on a restaurant a carosel of pictures pops up, and if you click a picture it adds it to the current order.
Now someone decides to reuse that map to show previous pickup orders, unaware that if the user were to click on a different restaurant, and then click on an image in the popped up carosel it would crash the app because there is no "current order" context.
Extracting state
With all this complexity, you might be wondering: "well, why store my state in React at all?"
The short version is because React needs to know when state changes so it can rerender. If you were to store all of your state in a global variable and just tell the root-most React element to rerender, React would have no idea which child, or memoized, components to rerender.
If you were particularly ambitious you might ask, "well, can I keep track of which components need to be rerendered and then tell that to React", and the answer is yes and there are libraries that will do that for you.
External state stores
These libraries are called external state stores, and were created in response to the issue of having complex application state used at many levels in the application's component tree.
The most popular libraries here are:
- Redux (opens in a new tab): Probably the most popular state manager (and one of the first); it was intended as a general purpose library (and can be used without) React, but has solid support with custom React hooks.
- Recoil (opens in a new tab): Second to Redux, this is a state manager focused solely on React.
- Zustand (opens in a new tab): The lightest-weight option, makes it very easy to define small state stores.
- MobX (opens in a new tab): Ultra-focused on performance, but somewhat of a boilerplate-heavy syntax; it was introduced to me as the "enterprise option", and in many ways I agree with that analysis.
Unlike other technologies in the React ecosystem, I don't think there is a clear winner here.
All of these libraries have:
- some way to define state
- some way to read state
- some way to set state
Here is an example of what working with one of these libraries, in this case MobX, is like:
// State is defined as a class with both getters and settings
class TodoState {
// state is defined as properties
public todos = []
constructor() {
makeAutoObservable(this);
}
// setters are defined as actions
addTodo(todo) {
this.todos.push(todos)
}
}
const GLOBAL_TODO_STATE = new TodoState();
// All components that might read state must be wrapped in the `observer` higher order component
// MobX will automatically rerender this if any state that it read during its last render changes
const TodoList = observer(() => {
return <div>
{GLOBAL_TODO_STATE.todos.map((todo) => {
// ...
})}
</div>;
})
That said, you don't only have to store MobX globally, you can also store it locally:
function App() {
const state = useState(() => new TodoState());
return <Children state={state} />
}
This is better than prop drilling because you can wrap all of your states in one object; in many ways, this is a type-safe context, as you can explicitly declare your components dependencies and the component only rerenders if the specific properties of the state that it read change.
Prefer local state
I'll caution against is that as you have a solution for a global state store, you might be tempted to put ALL of your state in there. Before you clutter your state store, two quesitons to ask are:
- Does this state really need to be read from two different places in the tree (if you can, keep it local)?
- Is there another way to refactor this to avoid global state (composition, render props)?
One thing I would caution you against is reading text input values directly from a global state store:
// DO NOT DO THIS
<input type='text' value={GLOBAL_STATE_STORE.value.get()} onChange={GLOBAL_STATE_STORE.value.set} />
Setting values in all of the external state managers may not be instant (they all have some overhead), and if React rerenders before it's been state the user may experience jumps or flickers while typing.
React is not MVC
One thing I'll say is that as people start to run into issues of state management and prop drilling, I've seen developers turn to other, older patterns such as MVC or MVVM. React is not cleanly MVC or MVVM, and--in my experience--forcing your code to conform to either of those patterns is going to result in less-React-y code with heavy boilerplate and frustrating self-imposed restrictions.