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.
Top comments (15)
Nice thank tout
Everytime, I see post about State
I suggest to give an eye to Meiosis pattern
Can you elaborate? I totally don't know what you mean 😉
I think it is this one meiosis.js.org/
I haven't read much about it though.
Read it, you won't regret it 🙂
Not a fan. I've seen people convert to stream based centralized state management and it hasn't worked well from performance perspective. Might be that they've done something wrong, but haven't seen them being able to fix the issue and it's been 1½ years since the change. They did the change because it resulted to "shorter and neater code".
Yes, in my experience we always should start from less expressive tool and only if it doesn't work for us search for more sophisticated solutions.
Excuse-me,
Here is a link to this concept
Meiosis
You can apply it to any front-end framework you want
Thanks. Took a look, looks very interesting.
😊 yes it is
Once you try it, you will adopt it
I have posted several articles on it on devto,
here is one or them reactmeio
Regards
Thank you for such an excellent series.
Just one small point. React can help us with delaying the evaluation:
const lionExists = useMemo(() =>
animals.some(animal => animal.type === 'lion'), [animals]);
So, it will be recalculated only when animals is changed.
Yes, that is very good comment. Thank you Enes.
In regard to the topic of calculated/derived state properties, I always ask myself the two questions:
In case the answer is yes to both questions then one property can be derived from the other (the base one).
This has served me well so far ;)
Thanks very informative!
amazing article!
state modelling is TOO important