Handling State & User Interactions In UI Applications
February 5, 2021 • ☕️☕️ 8 min read
There are multiple types of state you need to handle in your app. Understanding the different types, and the best approaches to model and handle each one, makes your app less complicated , more componentized, and easier to test.
You can think of your app or component state as a snapshot of the app at some point of its lifecycle, for example If we have a list of items, we could have these states:
- Loading state.
- Data Loaded state.
- Network Error state.
- No Data state.
And basically the job of your business logic is to handle the transitions between these states without breaking the app.
We usually represent each state with a set of variables e.g. loadingStatus, data, errorMessage
and at each state, those variables would have a different set of values or no values at all.
The problems many state management libraries try to solve when working with state is to:
- Avoid having your app in impossible states at the same time (e.g. a list cannot in loaded and error state at the same time).
- Prevent incorrectly setting state values.
- Predictably handle transitions between states.
A more high level of looking at the multiple types of state you app needs:
- Component Local/UI state.
- Shared (global? cross-component?) state.
- Remote/network/cache state.
Finite State Machines:
There are many approaches for state management, but the most framework agnostic and easier to maintain and understand I’ve found so far is to model your component/app state using state machines.
The more time you invest in building the state of your app before writing code, the less code you will need to rewrite and refactor while figuring out edge cases on the fly, that will make your code more maintainable and has fewer bugs.
State machines actually handle more than just state, they visually describe the interactions inside of the component you are designing, the different states it will be in, and the events (actions) that would move the component from one state to another.
A finite state machine can be represented by a directed graph where the graph nodes will be the different states and edges are the transitions (how you go from a state to another) and Events what tells us how to “transition” from one state to another.
And here is one of the most famous FSMs, a Promise:
Once you look at the above two graphs, you instantly can spot few major features of state machines and one of the reasons they are a robust way of modeling your app/component state:
-
You can be only at one state at a time (there is one exception to this which we will describe in a while).
-
The edges (transitions) go in one direction, the same transition cannot be reversed due to accidental event or so.
-
Might not be very apparent here, but another great feature of state machines is that you move your business logic from the events (bottom-up approach) to the states, so for example on a “FETCH” event, you will move to another state called “Pending”, the event itself doesn’t need to set values, or do any calculations, it is just an order to our machine that it should now transition to the “Pending” state.
Handling the business logic inside event handlers (from the amazing book ”Constructing the User Interface with Statecharts“)
Those three simple characteristics of state machines which you get out of the box might seem insignificant, but they just make an entire class of bugs impossible to happen before you even start coding.
The above Promise machine can be represented with json like this:
const promiseConfig = {
initial: 'idle',
states: {
idle: {
on: {
FETCH: {
target: 'pending'
}
}
},
pending: {
on: {
RESOLVE: {
target: 'resolved'
},
REJECT: {
target: 'rejected'
}
}
},
resolved: {},
rejected: {}
},
}
const promiseMachine = (state, event) => {
return promiseConfig.states[state]?.on?.[event.type] || state;
};
Note: In the upcoming examples I will be using the same json syntax of the library xstate, but you don’t have to use a library for most of your basic state machines, maybe just a switch..case or a useReducer if you are using react, Actually If you noticed the above promiseMachine
it is basically a reducer that takes current state + event and returns the new state.
State Charts:
Based on the use case, Finite State machines can get more complex and start to have so many states with redundant transitions, which can be referred to as State Explosion.
For example, consider you are building a state machine of a light bulb, it can be in two states ON or OFF.
But then imagine that the ON state can be in different colors like Red and Blue.
As you can see by adding only one more state (Color), the SWITCH_OFF action has been duplicated and the chart started to look messier, imagine having 2 or 3 more states, we will have more redundant transitions and will lose one big benefit of state machines, which is the ease of understanding how your application works and how it moves from one state to another.
To solve this problem, State Charts extends Finite state machine with few features:
Hierarchal (Nested) State Machines:
Parallel state machines:
Now lets imagine we have another state we want to add where the light can both have a color and a brightness level:
Combinatorial explosion is the rapid growth of the complexity of a problem due to how the number of combinations of the problem is affected by the input, constraints, and bounds of the problem.
State Charts solves the above state explosion by introducing parallel state machines:
Note: above we can have both Events as “SWITCH”, and both parallel states will respond to the event, but i changed it to “CHANGE_COLOR” as it felt more practical in this imaginary example.
Now let’s go beyond the simple or imaginary example and go through some real life component and see how to model its state with a state machine, and we will be learning some new concepts along the way:
Interview tip:
During your System Design interview, If the question is about building one component and not a full web app, I like to quickly draw the state chart to show the different states of the component and how It will move from one state to the other based on the user interactions.
Building an autocomplete/search component:
In the most basic case of an autocomplete or search input, you can easily imagine your component states as something like this:
- An “Idle” state where the input is setting there in its default styling and there are no results showing to the user.
- A “Searching” state when the user starts to type some characters and we are in the background fetching the data, we usually would show some loader to indicate the loading state of the results.
- Two Final states “Loaded” and “Failed” based on the network response with the search results or with an error.
And the state machine for it will look like this:
const searchMachine = {
id: "searchMachine",
initial: "idle",
context: {
query: "",
results: undefined,
error: undefined,
},
states: {
idle: {
on: {
SEARCH: {
actions: ["setQuery"],
target: "searching",
},
},
},
searching: {
invoke: {
src: "fetchResults",
onDone: { target: "success", actions: ["setResults"] },
onError: { target: "failure", actions: ["setError"] },
},
on: {
RESET: "idle"
}
},
success: {},
failure: {},
},
};
Simple states and straight forward transitions between them, but state charts shine in these situations when you look at the above chart and start thinking of the full user experience and the edge cases even before writing any code:
- What will happen if the user’s query is invalid e.g. one character, symbols, etc.
- What if we get empty results from the server?
- What if the search request fails due to different reasons, could be user related like a slow connection or being offline, or server related.
- Should we have retries on failure?
- What if the user is typing too fast and you want to optimize the number of network calls your app is making.
Let’s go through how to change our state machine to handle some of the above edge cases:
1- Retries on failure:
We will first introduce two new features of state machines to be able to handle this case:
- Guarded Transitions: in state machines you can define a transition to happen only when a specific condition is true, otherwise it can either do nothing or fallback to another transition. read more about it with more examples here.
- Extended State (Context): the non-finite parts of the state machine, the state that represents quantitative data like numbers, strings, or objects which can have any values is represented as extended state (in XState it is called Context).
Now let’s introduce an extended state variable called retiresCount
, which we will be updating on each RETRY
event, which when triggered will either retry the search by moving to the “searching” state again based on a guard that checks for the value of retriesCount
or move to the permanentFailure
state, as represented here:
2- Handle Empty Server Results:
To achieve this step, we need to introduce another new concept in state machines:
- Transient transitions: It is a transition that is immediately taken without a need to trigger an event, you usually combine it with Guards to instruct the machine to move to another state based on some condition.
We will turn our “Success” state to a transient transition which will either move to an “Empty Results” state or “Results” state based on a guard that checks for the results we got from the server:
You can view this state machine here https://xstate.js.org/viz/?gist=578baab45f2b62885862e407e82dff31
And here is how we can represent it with json:
{
id: "searchMachine",
context: {
search: "",
result: {},
error: {},
},
initial: "blank",
states: {
blank: {
entry: "setQuery"
},
invalid: { entry: "setQuery" },
searching: {
invoke: {
id: "doSearch",
src: "fetchResults",
onDone: {
target: "searchSuccess",
actions: "setResult"
},
onError: {
target: "searchFailure",
actions: "setError"
}
}
},
searchSuccess: {
on: {
'': [
{
cond: "resultsEmpty",
target: "searchNoResults"
},
{
target: "searchResults"
}
]
}
},
searchResults: {},
searchNoResults: {},
searchFailure: {},
},
on: {
SEARCH: [
{
cond: "searchIsValid",
target: "searching"
},
{
cond: "searchIsEmpty",
target: "blank"
},
{
target: "invalid"
}
]
}
}
In the above state machine you will notice that we moved the SEARCH event to the root of the machine, so instead of repeating it again and again in every state, we can add it there, that is possible because xstate has an implicit
root
state for any machine by default.
3**- Handle invalid queries:**
To handle invalid queries, we could just break down our idle state to blank and invalid states which we will move to each based on a guard:
https://xstate.js.org/viz/?gist=e47cc160cbe857f6ade54ad1929d6907
{
id: "searchMachine",
context: {
search: "",
result: {},
error: {},
retriesCount: 0
},
initial: "blank",
states: {
blank: {
entry: "setQuery"
},
invalid: { entry: "setQuery" },
searching: {
invoke: {
id: "doSearch",
src: "fetchResults",
onDone: {
target: "searchSuccess",
actions: "setResult"
},
onError: {
target: "searchFailure",
actions: "setError"
}
}
},
searchSuccess: {
on: {
'': [
{
cond: "resultsEmpty",
target: "searchNoResults"
},
{
target: "searchResults"
}
]
}
},
searchResults: {},
searchNoResults: {},
searchFailure: {
on: {
'': [
{
target: "searching",
actions: ["setRetriesCount"],
cond: "canRetry"
},
{ target: "permanentFailure" }
]
}
},
permanentFailure: {}
},
on: {
SEARCH: [
{
cond: "searchIsValid",
target: "searching"
},
{
cond: "searchIsEmpty",
target: "blank"
},
{
target: "invalid"
}
]
}
}
4**- What if the user is typing too fast and you want to optimize the number of network calls?**
To achieve that, we need to introduce two new concepts:
-
Debouncing:
In which we delay the execution of a function for a specific amount of time from the last time it was called.
Debouncing vs throttling:
Debouncing a function for 500ms means: execute the function 500ms after it is called, and calls during the 500ms will reset the timeout. Throttling a function for 500ms means: execute this function only one time every 500ms and ignore the other calls in between. recommended read: https://css-tricks.com/the-difference-between-throttling-and-debouncing/
-
Delayed Transitions:
In state machines, We can define a transition to happen
after
a specific amount of time, so instead of moving right away to the new state, the machine will wait for some time before moving to the next state.
In this case as you can see in the below state chart, we added a new state “debouncing” which we will delay for a certain timeout, then move to “searching” state as usual. This will guarantee that our searching functionality happens only after the user stops typing for x ms.
https://xstate.js.org/viz/?gist=becd77ab031a52d9cb60f1548512c355
You can try the full working example here on codesandbox:
Search Example with Xstate and React
State charts are a reasonable replacement of natural language technical specs, You can use them to quickly visualize how a component or an entire application will work, and handle many edge cases before writing any code.
That was a quick overview of states and statecharts and how to use them, Hopefully by now you can draw a state diagram of most problems, but If you want to get deeper, here are some resources for you: