Explicit states for predictable user experiences
Take a look at a reference project using react-states: excalidraw-firebase.
VERSION 4 has some breaking changes:
- Removed useStates hook, as new patterns made it obvious that the core helpers should be used instead
- transition is now transitions and it returns a reducer
- map is now match with improved typing
- No need to add DevtoolManager anymore
- There is a new renderReducerHook test helper
Your application logic is constantly bombarded by events. Some events are related to user interaction, others from the browser. Also any asynchronous code results in resolvement or rejection, which are also events. We typically write our application logic in such a way that our state changes and side effects are run as a direct result of these events. This approach can create unpredictable user experiences. The reason is that users treats our applications like Mr and Ms Potato Head, bad internet connections causes latency and the share complexity of a user flow grows out of hand and out of mind for all of us. Our code does not always run the way we intended it to.
react-states is at its core 3 utility functions made up of 20 lines of code that will make your user experience more predictable in React.
NOTE! This documentation is a good read if you have no intention of using the tools provided. It points to complexities that we rarely deal with in application development and is good to reflect upon :-)
A typical way to express state in React is:
const [todos, dispatch] = React.useReducer(
(state, action) => {
switch (action.type) {
case 'FETCH_TODOS':
return { ...state, isLoading: true };
case 'FETCH_TODOS_SUCCESS':
return { ...state, isLoading: false, data: action.data };
case 'FETCH_TODOS_ERROR':
return { ...state, isLoading: false, error: action.error };
}
},
{
isLoading: false,
data: [],
error: null,
},
);
This way of expressing state has issues:
- We are not being explicit about what states this reducer can be in:
NOT_LOADED
,LOADING
,LOADED
andERROR
- There is one state not expressed at all,
NOT_LOADED
- There is no internal understanding of state when an action is handled. It will be handled regardless of the current state of the reducer
A typical way to express logic in React is:
const fetchTodos = React.useCallback(() => {
dispatch({ type: 'FETCH_TODOS' });
axios
.get('/todos')
.then((response) => {
dispatch({ type: 'FETCH_TODOS_SUCCESS', data: response.data });
})
.catch((error) => {
dispatch({ type: 'FETCH_TODOS_ERROR', error: error.message });
});
}, []);
This way of expressing logic has issues:
- The logic of
fetchTodos
is at the mercy of whoever triggers it. There is no explicit state guarding that it should run or not - You have to create callbacks that needs to be passed down as props
A typical way to express dynamic rendering in React is:
const Todos = ({ todos }) => {
let content = null;
if (todos.error) {
content = 'There was an error';
} else if (todos.isLoading) {
content = 'Loading...';
} else {
content = (
<ul>
{todos.map((todo) => (
<li>{todo.title}</li>
))}
</ul>
);
}
return <div className="wrapper">{content}</div>;
};
This way of expressing dynamic render has issues:
- Since the reducer has no explicit states, it can have an
error
andisLoading
at the same time, it is not necessarily correct to render anerror
over theisLoading
state - It is not very appealing is it?
If you want to look at a real project using this approach, please visit: excalidraw-firebase.
import { transitions, exec, match } from 'react-states';
type Context =
| {
state: 'LOADING';
}
| {
state: 'LOADED';
data: [];
}
| {
state: 'ERROR';
error: string;
};
type Action =
| {
type: 'FETCH_TODOS';
}
| {
type: 'FETCH_TODOS_SUCCESS';
data: Todo[];
}
| {
type: 'FETCH_TODOS_ERROR';
error: string;
};
const todosReducer = transitions<Context, Action>({
NOT_LOADED: {
FETCH_TODOS: (): Context => ({ state: 'LOADING' }),
},
LOADING: {
FETCH_TODOS_SUCCESS: ({ data }): Context => ({ state: 'LOADED', data }),
FETCH_TODOS_ERROR: ({ error }): Context => ({ state: 'ERROR', error }),
},
LOADED: {},
ERROR: {},
});
const Todos = () => {
const [todos, dispatch] = useReducer(todosReducer, { state: 'NOT_LOADED' });
useEffect(
() =>
exec(todos, {
LOADING: () => {
axios
.get('/todos')
.then((response) => {
dispatch({ type: 'FETCH_TODOS_SUCCESS', data: response.data });
})
.catch((error) => {
dispatch({ type: 'FETCH_TODOS_ERROR', error: error.message });
});
},
}),
[todos],
);
return (
<div className="wrapper">
{match(todos, {
NOT_LOADED: () => 'Not loaded',
LOADING: () => 'Loading...',
LOADED: ({ data }) => (
<ul>
{data.map((todo) => (
<li>{todo.title}</li>
))}
</ul>
),
ERROR: ({ error }) => error.message,
})}
</div>
);
};
- The todos will only be loaded once, no matter how many times
FETCH_TODOS
is dispatched - The logic for actually fetching the todos will also only run once, because it is an effect of
moving into the
LOADING
state - We only need
dispatch
now - We are explicit about what state the reducer is in, meaning if we do want to enable fetching the todos several times we can allow it in the
LOADED
state, meaning you will at least not fetch the todos while they are already being fetched - We are taking full advantage of TypeScript, which helps us keep our state and UI in sync
The solution here is not specifically related to controlling data fetching. It is putting you into the mindset of explicit states and guarding the state changes and execution of side effects. It applies to everything in your application, especially async code
By adding the DevtoolsProvider
to your React application you will get insight into the history of state changes, dispatches, side effects and also look at the definition of your transitions
right from within your app.
import * as React from 'react';
import { render } from 'react-dom';
import { DevtoolsProvider } from 'react-states/devtools';
import { App } from './App';
const rootElement = document.getElementById('root');
render(
<DevtoolsProvider>
<App />
</DevtoolsProvider>,
rootElement,
);
import { transitions, useDevtools } from 'react-states/devtools';
const reducer = transitions({});
const SomeComponent = () => {
const someReducer = useReducer(reducer);
useDevtools('my-thing', someReducer);
};
Since there is no need for callbacks we have an opportunity to expose features as context providers which are strictly driven by dispatches and explicit states to drive side effects.
const context = createContext(null);
type Context =
| {
state: 'UNAUTHENTICATED';
}
| {
state: 'AUTHENTICATING';
}
| {
state: 'AUTHNETICATED';
user: { username: string };
}
| {
state: 'ERROR';
error: string;
};
type Action =
| {
type: 'SIGN_IN';
}
| {
type: 'SIGN_IN_SUCCESS';
user: { username: string };
}
| {
type: 'SIGN_IN_ERROR';
error: string;
};
export const useAuth = () => useContext(context);
const authReducer = transitions<Context, Action>({
UNAUTHENTICATED: {
SIGN_IN: (): Context => ({ state: 'AUTHENTICATING' }),
},
AUTHENTICATING: {
SIGN_IN_SUCCESS: ({ user }): Context => ({ state: 'AUTHENTICATED', user }),
SIGN_IN_ERROR: ({ error }): Context => ({ state: 'ERROR', error }),
},
AUTHENTICATED: {},
ERROR: {},
});
export const AuthProvider = ({ children }) => {
const value = useReducer(authReducer, {
state: 'UNAUTHENTICATED',
});
const [auth, dispatch] = value;
useEffect(
() =>
exec(auth, {
AUTHENTICATING: () => {
axios
.get('/signin')
.then((response) => {
dispatch({ type: 'SIGN_IN_SUCCESS', user: response.data });
})
.catch((error) => {
dispatch({ type: 'SIGN_IN_ERROR', error: error.message });
});
},
}),
[auth],
);
return <context.Provider value={value}>{children}</context.Provider>;
};
Sometimes you might have one or multiple handlers across states. You can lift them up and compose them back into your transitions.
There are three parts to this patterns:
- Only type what you need from an action, not the actions themselves
- If you are consuming the current context, type it as any context (
Context
) and optionally restrict it with properties and values you want to be available on that context - Always give any context (
Context
) as the return type
You can define a single action handler:
import { transitions } from 'react-states';
const handleChangeDescription = (
// Expressing what we want from the action
{ description }: { description: string },
// Expressing that we allow any context, as long as it has an existing "description" on it
currentContext: Context & { description: string },
// Allowing us to move into any context
): Context => ({
...currentContext,
description,
});
const reducer = transitions<Action, Context>({
FOO: {
CHANGE_DESCRIPTION: handleChangeDescription,
},
BAR: {
CHANGE_DESCRIPTION: handleChangeDescription,
},
});
Or multiple action handlers:
import { transitions } from 'react-states';
const globalActionHandlers = {
CHANGE_DESCRIPTION: ({ description }: { description: string }, currentContext: Context): Context => ({
...currentContext,
description,
}),
};
const reducer = transitions<Action, Context>({
FOO: {
...globalActionHandlers,
},
BAR: {
...globalActionHandlers,
},
});
You can use match
for more than rendering a specific UI. You can for example use it for styling:
<div
css={match(someContext, {
STATE_A: () => ({ opacity: 1 }),
STATE_B: () => ({ opacity: 0.5 }),
})}
/>
You can even create your own UI metadata related to a state which can be consumed throughout your UI definition:
const ui = match(someContext, {
STATE_A: () => ({ icon: <IconA />, text: 'foo', buttonStyle: { color: 'red' } }),
STATE_B: () => ({ icon: <IconB />, text: 'bar', buttonStyle: { color: 'blue' } }),
});
ui.icon;
ui.text;
ui.buttonStyle;
You might have functions that only deals with certain states.
import { match, PickState } from 'react-states';
function mapSomeState(context: PickState<Context, 'A' | 'B'>) {
return match(context, {
A: () => {},
B: () => {},
});
}
match
will infer the type of context and ensure type safety for the subtype.
Sometimes you have multiple states sharing the same base context. You can best express this by:
type BaseContext = {
ids: string[];
};
type Context =
| {
state: 'NOT_LOADED';
}
| {
state: 'LOADING';
}
| (BaseContext &
(
| {
state: 'LOADED';
}
| {
state: 'LOADED_DIRTY';
}
| {
state: 'LOADED_ACTIVE';
}
));
This expresses the simplest states first, then indents the states using the base context. This ensures that you see these states related to their base and with their indentation they have "special meaning".
You do not have to express the whole context at the root, you can split it up into nested contexts.
type ValidationContext =
| {
state: 'VALID';
}
| {
state: 'INVALID';
}
| {
state: 'PENDING';
};
type Context =
| {
state: 'ACTIVE';
value: string;
validation: ValidationContext;
}
| {
state: 'DISABLED';
};
Now any use of exec
or match
can be done on the sub contexts as well.
exec(context, {
ACTIVE: ({ validation }) =>
exec(validation, {
PENDING: () => {},
}),
});
match(context, {
DISABLED: () => ({}),
ACTIVE: ({ validation, focus }) =>
match(validation, {
VALID: () => ({}),
INVALID: () => ({}),
PENDING: () => ({}),
}),
});
You can control effects in four ways.
// 1. The FOO effect runs every time
// it enters the FOO state, and
// disposes entering any new state, including
// entering FOO again
useEffect(
() =>
exec(context, {
FOO: () => {},
}),
[context],
);
// 2. The FOO effect runs every time
// it enters the FOO state, and
// disposes only when moving out of the
// FOO state
useEffect(
() =>
exec(context, {
FOO: () => {},
BAR: () => {},
}),
[context.state],
);
// 3. The FOO effect runs every time
// it enters the FOO state, and
// disposes when moving to BAZ state, or
// unmounts
const shouldSubscribe = match(context, {
FOO: () => true,
BAR: () => true,
BAZ: () => false,
});
useEffect(
() =>
shouldSubscribe &&
exec(context, {
FOO: () => {},
}),
[shouldSubscribe],
);
Creates an explicit and guarded reducer.
type Context =
| {
state: 'FOO';
}
| {
state: 'BAR';
};
type Action = {
type: 'SWITCH';
};
const reducer = transitions<Context, Action>({
FOO: {
// Currently you should explicitly set the return type of the
// handlers to the context, this will be resolved when
// TypeScript gets Exact types: https://github.com/Microsoft/TypeScript/issues/12936
SWITCH: (action, currentContext): Context => ({ state: 'BAR' }),
},
BAR: {
SWITCH: (action, currentContext): Context => ({ state: 'FOO' }),
},
});
useReducer(reducer);
The state argument is called context as it represents multiple states. The context should have a state property.
{
state: 'SOME_STATE',
otherValue: {}
}
transitions
expects that your reducer actions has a type property:
{
type: 'SOME_EVENT',
otherValue: {}
}
useEffect(
() =>
exec(someContext, {
SOME_STATE: (currentContext) => {},
}),
[someContext],
);
The effects works like normal React effects, meaning you can return a function which will execute when the state changes:
useEffect(
() =>
exec(someContext, {
TIMER_RUNNING: () => {
const id = setInterval(() => dispatch({ type: 'TICK' }), 1000);
return () => clearInterval(id);
},
}),
[someContext],
);
The exec is not exhaustive, meaning that you only add the states necessary.
const result = match(context, {
SOME_STATE: (currentContext) => 'foo',
});
Is especially useful with rendering:
return (
<div className="wrapper">
{match(todos, {
NOT_LOADED: () => 'Not loaded',
LOADING: () => 'Loading...',
LOADED: ({ data }) => (
<ul>
{data.map((todo) => (
<li>{todo.title}</li>
))}
</ul>
),
ERROR: ({ error }) => error.message,
})}
</div>
);
The match is exhaustive, meaning you have to add all states. This ensures predictability in the UI.
Safe async resolvement. The API looks much like the Promise API, though it has cancellation and strong typing built in. This is inspired by the Rust language.
import { result } from 'react-states';
const res = result<{}, { type: 'ERROR'; data: string }>((ok, err) =>
// You return a promise from a result, this promise
// should never throw, but rather return an "ok" or "err"
doSomethingAsync()
.then((data) => {
return ok(data);
})
.catch((error) => {
return err('ERROR', error.message);
}),
);
const cancel = res.resolve((data) => {}, {
ERROR: (data) => {},
});
// Cancels the resolver
cancel();
You can return a result resolver from the resolve callback. Any cancellation from the top cascades down to the currently running resolver.
This is a test helper, which allows you to effectively test any reducers exposed through a context provider. It does this by keeping the same object reference for the context and rather updates that (mutates) whenever the reducer updates. This way you can reference the context multiple times, even though it changes.
import { renderReducerHook } from 'react-states/test';
test('should go to FOO when switching', () => {
const [context, dispatch] = renderReducerHook(
() => useSomeContextProviderExposingAReducer(),
(HookComponent) => (
<ContextProviderExposingReducer>
<HookComponent />
</ContextProviderExposingReducer>
),
);
expect(context).toEqual<Context>({
state: 'FOO',
});
act(() => {
dispatch({ type: 'SWITCH' });
});
expect(context).toEqual<Context>({
state: 'BAR',
});
});
react-states
exposes the PickState
and PickAction
helper types. Use these helper types when you "lift" your action handlers into separate functions.
type Context =
| {
state: 'FOO';
}
| {
state: 'BAR';
};
type Action =
| {
type: 'A';
}
| {
type: 'B';
};
const actions = {
A: (action: PickAction<Action, 'A'>, context: PickState<Context, 'FOO'>) => {},
B: (action: PickAction<Action, 'B'>, context: PickState<Context, 'FOO'>) => {},
};
const reducer = transitions<Context, Action>({
FOO: {
...actions,
},
BAR: {},
});
Me learning state machines and state charts is heavily influenced by @davidkpiano and his XState library. So why not just use that? Well, XState is framework agnostic and needs more concepts like storing the state, sending events and subscriptions. These are concepts React already provides with reducer state, dispatches and the following reconciliation. Funny thing is that react-states is actually technically framework agnostic, but its API is designed to be used with React.