The state you've never needed
Pragmatic Maciej
Posted on January 12, 2021
Every application has a state. State represents our application data and changes over time. Wikipedia describes state as:
A computer program stores data in variables, which represent storage locations in the computer's memory. The contents of these memory locations, at any given point in the program's execution, is called the program's state
And the most important part of this quote is "at any given point", what means that state changes over time. And that is the reason why managing state is one of the hardest thing we do. If you don't believe me, then remind yourself how often you needed to restart computer, tv or phone when it hangs or behave in strange way. That exactly, are state issues.
In the article I will show examples from managing state in React, but the advice I want to share is more broad and universal.
Where is the lion
Below code with some state definition by useState hook.
const [animals, setAnimals] = useState([]);
const [lionExists, setLionExists] = useState(false);
// some other part of the code... far far away 🌴
setAnimals(newAnimals);
const lionExists = newAnimals
.some(animal => animal.type === 'lion');
setLionExists(lionExists);
What we can see here is clear relation between animals
and lionExists
. Even more, the latter is calculated from the former, and in the way that nothing more matters. It really means whenever we change animals
, we need to recalculate if lion exists again, and if we will not do that, welcome state issues. And what issues exactly? If we change animals
and forget about lionExists
then the latter does not represent actual state, if we change lionExists
without animals
, again we have two sources of truth.
The lion exists in one dimension
My advice for such situation is - if your state can be recalculated from another, you don't need it. Below the code which can fully replace the previous one.
const [animals, setAnimals] = useState([]);
const lionExists = (animals) => {
return animals.some(animal => animal.type === 'lion');
};
// in a place where we need information about lion
if (lionExists(animals)) {
// some code
}
We have two benefits here:
✅ We've reduced state
✅ We've delayed computation by introducing function
But if this information is always needed? That is a good question, if so, we don't need to delay the computation, but we just can calculate that right away.
const [animals, setAnimals] = useState([]);
const lionExists =
animals.some(animal => animal.type === 'lion');
And now we have it, always, but as calculated value, and not state variable. It is always recalculated when animals change, but it will be also recalculated when any other state in this component change, so we loose second benefit - delayed computation. But as always it depends from the need.
What about issues here, do we have still some issues from first solution? Not at all. Because we have one state, there is one source of truth, second information is always up to date. Believe me, less state, better for us.
Error, success or both? 🤷♂️
const [errorMsg, setErrorMsg] = null;
const [hasError, setHasError] = false;
const [isSuccess, setIsSuccess] = false;
// other part of the code
try {
setSuccess(true);
}
catch (e) {
setErrorMsg('Something went wrong');
setHasError(true);
}
This one creates a lot of craziness. First of all, as error and success are separated, we can have error and success in the one time, also we can have success and have errorMsg set. In other words our state model represents states in which our application should never be. Amount of possible states is 2^3, so 8 (if we take into consideration only that errorMsg is set or not). Does our application have eight states? No, our application has three - idle state (normal, start state or whatever we will name it), error and success, so how come we did model our app as state machine with eight states? That is clearly not the application we work on, but something few times more complicated.
The pitfall of bad glue
In order to achieve consistent state we need to make changes together. So when we have error, 3 variables need to change:
setErrorMsg('Something went wrong');
setHasError(true);
setSuccess(false);
and when success also:
setErrorMsg(null);
setHasError(false);
setSuccess(true);
Quite a burden to always drag such baggage with us, and remember how these three state variables relates to each other.
Now let's imagine few issues created by such state model:
⛔ We can show error message when there is success state of the app.
⛔ We can have error, but empty box with error message
⛔ We can have both success and error states visible in UI
One state to rule them all 💍
I said our app has three states. Let's then model it like that.
const [status, setStatus] = useState(['idle']);
// other part of the code
try {
// some action
setStatus(['success']);
}
catch (e) {
setStatus(['error', 'Something went wrong']);
}
Now we can also make functions which will clearly give our status a meaning:
const isError = ([statusCode]) => statusCode === 'error';
const isSuccess = ([statusCode]) => statusCode === 'success';
const errorMsg = (status) => {
if (!isError(status)) {
throw new Error('Only error status has error message');
}
const [_, msg] = status;
return msg;
}
What benefit this solution has:
✅ We've reduced state variables
✅ We removed conflicting states
✅ We removed not possible states
Our application uses single state to model application status, so there is no way to have both success and error in one time, or have error message with success 👍. Also thanks to state consolidation, we don't need to remember what to change, and what variable is variable relation. We just change one place.
Few words about implementation. I have used tuple, because tuples are ok, but we could be using key-value map like {statusCode:'error', msg: 'Something went wrong'}
, that also would be fine. I also made exception in errorMsg
as I believe such wrong usage should fail fast and inform developer right away that only error can have an error message.
Add some explicit types
TypeScript can help with more explicit state modelling. Let's see our last example in types.
type Status = ['idle'] | ['success'] | ['error', string ];
const [status, setStatus] = useState<Status>(['idle']);
Above TS typying will allow for no typos, and always when we would like to get error message, TypeScript will force us to be sure it is error status, as only this one has message.
Summary
What I can say more. Putting attention at state modelling is crucially important. Every additional state variable multiplicates possible states of the app, reducing state reduce the complexity.
If something can be calculated from another it should not be state variable, if things change together, consolidate them. Remember the simplest to manage are things which does not change, so constants, next in the line are calculations, so pure functions which for given argument always produce the same value, and the last is state. State is most complicated because it changes with time.
Posted on January 12, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.