React: 50 shades of state
We all know that one of the most challenging task in software development is state management. This is especially true for the JavaScript world. There are thousands of articles on this topic and so I decided to write another one ๐. I wanted to share my current thoughts on the different state types. I found that answering "What lives where?" question is far more important than the actual state management. Which more or less is an implementation detail.
Local state
Let's first start with the most simpler case. A state that lives only inside a single component. I like to call such state "local" or "UI" state. Normally it helps us manage user interface interactions. Like for example showing and hiding content or enabling and disabling a button. It is also very often controlling what gets rendered while we wait for something. Consider the following example:
function Text() {
const [viewMore, setViewMore] = useState(false);
return (
<Fragment>
<p>
React makes it painless to create interactive UIs.
{
viewMore && <span>
Design simple views for each state in your application.
</span>
}
</p>
<button onClick={() => setViewMore(true)}>read more</button>
</Fragment>
);
}
viewMore
is a state that only makes sense for this particular component. Its job is to control the visibility only on the text here. How to find such type of state? Ask yourself who needs to know about it. In this example, the other components of the application, will probably have no interest in the viewMore
flag. So you don't have to leak this state outside of the Text
component.
Feature state
The next type of state combines two or more components. And by combines I mean that those components need to know the same information. Such state I define as a "feature" state. One may say that every state that is not local falls into this category. However, not every feature state is the same and we will see that further in the article.
A good example for feature state is a form state. It looks a bit like the UI state described above but combines multiple inputs. Manages multiple components.
const Skill = ({ onChange }) => (
<label> Skill:
<input type="text" onChange={e => onChange(e.target.value)}/>
</label>
);
const Years = ({ onChange }) => (
<label> Years of experience:
<input type="text" onChange={e => onChange(e.target.value)}/>
</label>
);
function Form() {
const [skill, setSkill] = useState('');
const [years, setYears] = useState('');
const isFormReady = skill !== '' && years !== '';
return (
<form>
<Skill onChange={setSkill} />
<Years onChange={setYears} />
<button disabled={!isFormReady}>submit</button>
</form>
);
}
Here we have a form with two text fields. The user types in a skill/competence and years of experience. The button is disabled by default and becomes enabled only if both inputs have values. Notice how skills
and years
are needed close to each so we can calculate isFormReady
. Form
is a perfect place for implementing such logic because it wraps all the elements containing the data of interest. Very often such Form
components constructs also a data object containing all the information and send it out. Either by directly making a request to an endpoint or passing it to a callback provided via prop. That is why those components have access to all the fields and their values. In our case there is also one computed state value - the isFormReady
constant.
We should recognize the feature state earlier before it gets hoisted up and becomes an application state.This example shows the really tin line between the feature and application state. It is not a big deal to use Redux here and keep the skill and years of experience in the store. Then defining a short selector isFormReady
and wire everything to the Form
component. This however I consider a bad practice now. We should recognize the feature state earlier before it gets hoisted up and becomes an application state.
Application state
An application state is the state that leads the overall experience of the user. This could be the authorization status, profile data or global style theme. It is potentially needed everywhere in the application. Here is a quick example:
const ThemeContext = React.createContext();
const Theme = ({ onChange }) => {
const { theme } = useContext(ThemeContext);
return `Theme: ${theme}`;
}
const ThemeSelector = () => {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<select value={theme} onChange={toggleTheme}>
<option value="light">light</option>
<option value="dark">dark</option>
</select>
);
}
function App() {
const [theme, setTheme] = useState('light');
const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<header>
<Theme />
</header>
<footer>
<ThemeSelector />
</footer>
</ThemeContext.Provider>
);
}
Here we have a header and a footer. They both need to know the current theme of the app. The example is simple but imagine that Theme
and ThemeSelector
are components deeply nested into other components. So, they do not have direct access to the theme
variable. They also use it for different things. Theme
only shows the current value while ThemeSelector
changes it.
We can recognize such state quicker than the feature state. If something is used widely and needs to be updated from distant components then we probably have to put it into the application state. This is also the most tempting action from our side. So, be cautious and think twice before doing it. Consider using feature or even local state instead. It will make your life easier.
Server state
The server type of state is something which I found just recently. A few weeks ago I listen a podcast which was about separating UI state and server state. After that I realized how complicated my life is. I'm working on two big projects that embrace the idea of universal applications. Writing the code once and use it on both client and server. This sounds good but after ~2 years working with such apps I would say - it makes everything complicated. The level of complexity goes up silently. Lots of different edge cases makes you do small patches.
The data is not changed very often and it is basically static for the client. So why even bother fetching it there.I'm now convinced in the separation of the state responsibilities. Let me give you a real case scenario. We have a bunch of data coming from Contentful. What we were doing so far was fetching this data with a service fired in a Redux saga logic. We pretty much use the same code on both lands except that on the client we check if the data is present in the store. If yes, then we are not making a request. In our case the Contentful data is a typical example of server-side state. This data is not changed very often and it is basically static for the front-end. So why even bother fetching it on the client. After refactoring we realized that the only big thing that happens on the server's Redux saga is the Contentful request. So we killed completely the saga usage on the server. We can now also safely remove the Contentful fetching from the client. No need to do that because the data is already present in the HTML.
To identify server state you have to think how often the data changes and where this data comes from. If it is more or less static then you may free the client of the responsibility to fetch it. You simply load it on the client and pass it as global variable.
Instruments
There are many different ways to do state management. I would even say that we have too many options to pick from. Let's explore some of the most used ones.
Hooks are the primary mechanism for state management
useState
(or useReducer
) is what most people use for managing local state. Pre hooks we were using component's setState
and its lifecycle methods. useState
is actually the primary mechanism for re-rendering in React. That's why most libraries for managing state are actually using that under the hood. If you dig deep enough you will see that all those awesome custom hooks provided by different third parties are relying on the default useState
, useReducer
, useEffect
.
Hoisting the state
The hooks API is quite flexible and could be used in various ways. We can safely use it to move state out of the components. This process is known as state hoisting. Some people also call it "lifting the state up". It is a process of extracting state and placing it somewhere up the component chain. Think about the view-more example in the beginning of the article. We have a local state to control the visibility of the text. But what if there is a new requirement that we have a global "expand text" button. Then we have to lift that state up and pass it down via prop.
// viewMore is a local state
function Component() {
const [viewMore, setViewMore] = useState(false);
return (
<Fragment>
<p>Text... { viewMore && <span>More text ...</span>}</p>
<button onClick={() => setViewMore(true)}>read more</button>
</Fragment>
);
}
// viewMore is a feature state
function Component({ viewMore }) {
return (
<p>Text... { viewMore && <span>More text ...</span>}</p>
);
}
You recognize the state hoisting when you see a local state variable becoming a prop. The main challenge of this approach is to find the balance in terms of prop drilling. You don't want to have many middle-man components that just pass props down their children.
Using context API
When we expand our view and reach the application state the game becomes a lot more interesting. The first thing that we should do I believe is to look at React itself. The combo of the context API and useState
is good enough to cover some of the application state needs. React context is here for a very long time. If I'm not mistaken, it is part of the library's first days. It was always declared as an experimental feature but popular libraries like Redux relied on it. It has one very important characteristic. It allows us to deliver data to every component in our tree no matter how deeply nested it is. It basically solves the problem of prop drilling and we normally use it when have to deal with application state.
Replying on third parties
Libraries like Redux, MobX and even the new kid on the block Recoil are also a good option for state management. However, we should know that they are mainly focusing on application state. My experience shows me that in most cases it is an overkill to apply them for local and feature state levels.
A client-server approach
There is one approach that I have no experience with. I'm seeing it in Apollo and ReactQuery. There may be some other ones too. It is basically adding a layer/client in your application that takes care for requesting/caching the data from the external source. It comes with some nice hooks and for the client it pretty much looks like using a local state. It removes ton of complexity around the data storing and fetching. Here's a quick example from the Apollo docs:
import { useQuery, gql } from '@apollo/client';
const EXCHANGE_RATES = gql`
query GetExchangeRates {
rates(currency: "USD") {
currency
rate
}
}
`;
function ExchangeRates() {
const { loading, error, data } = useQuery(EXCHANGE_RATES);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error :(</p>;
return data.rates.map(({ currency, rate }) => (
<div key={currency}>
<p>
{currency}: {rate}
</p>
</div>
));
}
We kind of specify how the data is fetched and the rest is all up to the Apollo client. We are getting a convenient loading
flag and an error
(if any). In this example we have GraphQL involved but libraries like ReactQuery allows us to use the same approach with a REST API. It is an interesting mixture between having a state on the front-end but in practice allowing that state to be managed on the back-end.
Conclusion
State management is hard. We have tons of tools to pick from and no best practices. It is one of those things that you find are wrong when it is too late. You see the problems only when the application grows. So, I hope this blog post helps you ask the right questions and find a better path for your state management.