The state you've never needed

Pragmatic Maciej - Jan 12 '21 - - Dev Community

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

and when success also:

  setErrorMsg(null);
  setHasError(false);
  setSuccess(true);
Enter fullscreen mode Exit fullscreen mode

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']);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player