react-states v7.2.0
react-states
Explicit states for predictable user experiences
- Problem statement
- Solution
- Predictable user experience by example
- As context provider
- Patterns
- TypeScript
- API
- Inspirations
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 3 utility functions made up of 20 lines of code that will make your user experience more predictable.
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 :-)
Problem statement
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?
Solution
import { useStates } from 'react-states';
const Todos = () => {
const todos = useStates(
{
NOT_LOADED: {
FETCH_TODOS: () => ({ state: 'LOADING' }),
},
LOADING: {
FETCH_TODOS_SUCCESS: ({ data }) => ({ state: 'LOADED', data }),
FETCH_TODOS_ERROR: ({ error }) => ({ state: 'ERROR', error }),
},
LOADED: {},
ERROR: {},
},
{ state: 'NOT_LOADED' },
);
useEffect(
() =>
todos.exec({
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.exec],
);
return (
<div className="wrapper">
{todos.transform({
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
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
Predictable user experience by example
Authentication
const Auth = () => {
const [auth, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
case 'SIGN_IN':
return { ...state, isAuthenticating: true };
case 'SIGN_IN_SUCCESS':
return { ...state, isAuthenticating: false, user: action.user };
case 'SIGN_IN_ERROR':
return { ...state, isAuthenticating: false, error: action.error };
}
},
{
user: null,
isAuthenticating: false,
error: null,
},
);
const signIn = useCallback(() => {
dispatch({ type: 'SIGN_IN' });
axios
.get('/signin')
.then(response => {
dispatch({ type: 'SIGN_IN_SUCCESS', user: response.data });
})
.catch(error => {
dispatch({ type: 'SIGN_IN_ERROR', error: error.message });
});
}, []);
return (
<button onClick={signIn} disabled={auth.isAuthenticating}>
Log In
</button>
);
};
Calling authenticate
twice is invalid behaviour, but this is not defined within your reducer. The only thing preventing your authentication logic from running multiple time, causing all sorts of weirdness, is an HTML attribute. This is fragile because you separate the logic. With explicit states you could rather:
const Auth = () => {
const auth = useStates(
{
UNAUTHENTICATED: {
SIGN_IN: () => ({ state: 'AUTHENTICATING' }),
},
AUTHENTICATING: {
SIGN_IN_SUCCESS: ({ user }) => ({ state: 'AUTHENTICATED', user }),
SIGN_IN_ERROR: ({ error }) => ({ state: 'ERROR', error }),
},
AUTHENTICATED: {},
ERROR: {},
},
{
state: 'UNAUTHENTICATED',
},
);
useEffect(
() =>
auth.exec({
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.exec],
);
return (
<button onClick={() => dispatch({ type: 'SIGN_IN' })} disabled={auth.context.state === 'AUTHENTICATING'}>
Log In
</button>
);
};
Now the application can dispatch as many SIGN_IN
as it wants, the reducer will only handle a single one whenever the current state is UNAUTHENTICATED
.Of course we still disable the button, but this is a property of the UI, it is not part of our application logic.
Initial data
Typically you want to load some initial data in your application. This might be loaded behind a TAB in the UI, meaning you pass down a callback to trigger the fetching of data when the child TAB component mounts.
const Tabs = () => {
const [list, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
case 'FETCH':
return { ...state, isLoading: true };
case 'FETCH_SUCCESS':
return { ...state, isLoading: false, data: action.data };
case 'FETCH_ERROR':
return { ...state, isLoading: false, error: action.error };
}
},
{
data: null,
isLoading: false,
error: null,
},
);
const fetchList = useCallback(() => {
dispatch({ type: 'FETCH' });
axios
.get('/list')
.then(response => {
dispatch({ type: 'FETCH_SUCCESS', data: response.data });
})
.catch(error => {
dispatch({ type: 'FETCH_ERROR', error: error.message });
});
}, []);
return (
<TabsComponent currentIndex={0} tabs={['List']}>
<List list={list} fetchList={fetchList} />
</TabsComponent>
);
};
The issue we have created now is that the mounting of our List
is what drives our logic. That means whenever the user would move away from this List
tab and back, it would trigger a new fetch of the list. But this is typically not what you want. The fetching of the list could also be slow, where the user again moves back and forth. That means when they move back the list might be there, but then suddenly it is set again because of a late resolvement causing UI flicker or more critical issues.
With explicit states:
const Tabs = () => {
const list = useStates(
{
NOT_LOADED: {
FETCH: () => ({ state: 'LOADING' }),
},
LOADING: {
FETCH_SUCCESS: ({ data }) => ({ state: 'LOADED', data }),
FETCH_ERROR: ({ error }) => ({ state: 'LOADED', error }),
},
LOADED: {},
ERROR: {},
},
{
state: 'NOT_LOADED',
},
);
useEffect(
() =>
list.exec({
LOADING: () => {
axios
.get('/list')
.then(response => {
listDispatch({ type: 'FETCH_SUCCESS', data: response.data });
})
.catch(error => {
listDispatch({ type: 'FETCH_ERROR', error: error.message });
});
},
}),
[list.exec],
);
return (
<TabsComponent currentIndex={0} tabs={['List']}>
<List list={list} />
</TabsComponent>
);
};
Now it does not matter how many times the List
component mounts. There will only be a single fetching of the list. If you wanted to fetch the list again when it was already loaded you could just do the following change:
const list = useStates(
{
NOT_LOADED: {
FETCH: () => ({ state: 'LOADING' }),
},
LOADING: {
FETCH_SUCCESS: ({ data }) => ({ state: 'LOADED', data }),
FETCH_ERROR: ({ error }) => ({ state: 'LOADED', error }),
},
LOADED: {
// We allow fetching again when we have loaded the data
FETCH: () => ({ state: 'LOADING' }),
},
ERROR: {},
},
{
state: 'NOT_LOADED',
},
);
But we would still never get into a situation when we are loading the list, that we start loading it again.
Logic within same state
Imagine you want to add and remove items from the list, where any new items are POSTed to the server and any updates are PATCHed to the server. We are going to deal with the real complexity of this:
- When you create an item it might fail
- When you update an item it might fail
- When changing an item being created, it needs to finish creating it before we send the update
- When updating an item being updated, it needs to finish updating it before we send the update
- When changing an item being created and the creation fails, we should not send the update
- When changing an item being updated and the update fails, we should not send the update
We will only care about the LOADED
state in this example and we introduce the same explicit state to every item:
const Items = () => {
const items = useStates(
{
LOADED: {
ADD_ITEM: ({ id, title }, { data }) => ({
state: 'LOADED',
data: { ...data, [id]: { id, title, state: 'QUEUED_CREATE' } },
}),
CHANGE_ITEM: ({ id, title }, { data }) => ({
state: 'LOADED',
data: {
...data,
[id]: {
id,
title,
state:
data[id].state === 'CREATING' || data[id].state === 'UPDATING'
? 'QUEUED_DIRTY'
: data[id].state === 'CREATE_ERROR'
? 'QUEUED_CREATE'
: 'QUEUED_UPDATE',
},
},
}),
CREATE_ITEM: ({ id }, { data }) => ({
state: 'LOADED',
data: { ...data, [id]: { ...data[id], state: 'CREATING' } },
}),
CREATE_ITEM_SUCCESS: ({ id }, { data }) => ({
state: 'LOADED',
data: {
...data,
[id]: { ...data[id], state: data[id].state === 'QUEUED_DIRTY' ? 'QUEUED_UPDATE' : 'CREATED' },
},
}),
CREATE_ITEM_ERROR: ({ id, error }, { data }) => ({
state: 'LOADED',
data: { ...data, [id]: { ...data[id], state: 'CREATE_ERROR', error } },
}),
UPDATE_ITEM: ({ id }, { data }) => ({
state: 'LOADED',
data: { ...data, [id]: { ...data[id], state: 'UPDATING' } },
}),
UPDATE_ITEM_SUCCESS: ({ id }, { data }) => ({
state: 'LOADED',
data: {
...data,
[id]: { ...data[id], state: data[id].state === 'QUEUED_DIRTY' ? 'QUEUED_UPDATE' : 'UPDATED' },
},
}),
UPDATE_ITEM_ERROR: ({ id, error }, { data }) => ({
state: 'LOADED',
data: { ...data, [id]: { ...data[id], state: 'UPDATE_ERROR', error } },
}),
},
},
{ state: 'NOT_LOADED' },
);
useEffect(
() =>
items.exec({
LOADING: [
function createItem({ data }) {
const queuedItem = Object.values(data).find(item => item.state === 'QUEUED_CREATE');
if (queuedItem) {
dispatch({ type: 'CREATE_ITEM', id: queuedItem.id });
axios
.post('/items', queuedItem)
.then(response => {
dispatch({ type: 'CREATE_ITEM_SUCCESS', data: response.data });
})
.catch(error => {
dispatch({ type: 'CREATE_ITEM_ERROR', error: error.message });
});
}
},
function updateItem({ data }) {
const queuedItem = Object.values(data).find(item => item.state === 'QUEUED_UPDATE');
if (queuedItem) {
dispatch({ type: 'UPDATE_ITEM', id: queuedItem.id });
axios
.patch('/items', queuedItem)
.then(response => {
dispatch({ type: 'UPDATE_ITEM_SUCCESS', data: response.data });
})
.catch(error => {
dispatch({ type: 'UPDATE_ITEM_ERROR', error: error.message });
});
}
},
],
}),
[items.exec],
);
};
This example shows the real complexity of doing optimistic updates and keeping our request to the server in order, also dealing with any errors that can occur. Typically we do not deal with this at all, but with explicit states we are drawn into reasoning about and model this complexity.
The lifetime of an item can now be:
QUEUED_CREATE
->CREATED
QUEUED_UPDATE
->UPDATED
QUEUED_CREATE
->CREATE_ERROR
QUEUED_UPDATE
->UPDATE_ERROR
QUEUED_CREATE
-> It was changed ->QUEUED_DIRTY
->QUEUED_UPDATE
->UPDATED
QUEUED_UPDATE
-> It was changed ->QUEUED_DIRTY
->QUEUED_UPDATE
->UPDATED
QUEUED_CREATE
-> It was changed ->QUEUED_DIRTY
->CREATE_ERROR
QUEUED_UPDATE
-> It was changed ->QUEUED_DIRTY
->UPDATE_ERROR
QUEUED_CREATE
->CREATE_ERROR
-> It was changed ->QUEUED_CREATE
As context provider
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);
export const useAuth = () => useContext(context);
export const AuthProvider = ({ children }) => {
const auth = useStates(
{
UNAUTHENTICATED: {
SIGN_IN: () => ({ state: 'AUTHENTICATING' }),
},
AUTHENTICATING: {
SIGN_IN_SUCCESS: ({ user }) => ({ state: 'AUTHENTICATED', user }),
SIGN_IN_ERROR: ({ error }) => ({ state: 'ERROR', error }),
},
AUTHENTICATED: {},
ERROR: {},
},
{
state: 'UNAUTHENTICATED',
},
);
useEffect(
() =>
auth.exec({
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.exec],
);
return <context.Provider value={auth}>{children}</context.Provider>;
};
Patterns
Lift actions
Sometimes you might have one or multiple handlers across states. You can lift them up and compose them back into your transitions.
const globalActions = {
CHANGE_DESCRIPTION: ({ description }, state) => ({
...state,
description,
}),
};
const reducer = (state, action) =>
transition(state, action, {
FOO: {
...globalActions,
},
BAR: {
...globalActions,
},
});
TypeScript
Using TypeScript with react-states
gives you a lot of benefits. Most of the typing is inferred, the only thing you really need to define is your explicit states and actions.
type User = { username: string };
type AuthContext =
| {
state: 'UNAUTHENTICATED';
error?: string;
}
| {
state: 'AUTHENTICATING';
}
| {
state: 'AUTHENTICATED';
user: User;
};
type AuthAction =
| {
type: 'SIGN_IN';
provider: 'google' | 'facebook';
}
| {
type: 'SIGN_IN_SUCCESS';
user: User;
}
| {
type: 'SIGN_IN_ERROR';
error: string;
};
You use these types on the reducer and the rest works itself out:
const auth = useStates<AuthContext, AuthAction>({
// All states are required to be defined
UNAUTHENTICATED: {
// Action types are optional
SIGN_IN: (
// Typed to SIGN_IN
action,
// Typed to UNAUTHENTICATED
state,
) => {},
},
AUTHENTICATING: {
SIGN_IN_SUCCESS: () => {},
SIGN_IN_ERROR: () => {},
},
AUTHENTICATED: {},
}),
);
useEffect(
() =>
auth.exec({
// Optional states
AUTHENTICATING: (
// Typed to AUTHENTICATING state
state,
) => {},
}),
[auth.exec],
);
const result = auth.transform({
// Optional states
UNAUTHENTICATED: (
// Typed to UNAUTHENTICATED
state,
) => null,
});
if (auth.context.state === 'AUTHENTICATED') {
// auth.context is now typed to AUTHENTICATED
}
Helper types
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 foo = useStates<Context, Action>({
FOO: {
...actions,
},
BAR: {},
});
API
useStates
This is the opinoinated single API. You can go lower level to control things more explicitly.
const foo = useStates(
{
SOME_STATE: {
SOME_ACTION_TYPE: (action, currentContext) => ({ state: 'NEW_STATE' }),
},
NEW_STATE: {},
},
{
state: 'SOME_STATE',
},
);
React.useEffect(
() =>
foo.exec({
NEW_STATE: context => {
// Do something when moving into NEW_STATE
},
}),
[foo.exec],
);
// Transform a state into a value, typically react nodes
const value = foo.transform({
SOME_STATE: context => 'foo',
NEW_STATE: context => 'bar',
});
transition
useReducer((state, action) =>
transition(state, action, {
SOME_STATE: {
SOME_ACTION_TYPE: (action, currentState) => ({ state: 'NEW_STATE' }),
},
}),
);
transition
expects that your reducer state has a state property:
{
state: 'SOME_STATE',
otherValue: {}
}
transition
expects that your reducer actions has a type property:
{
type: 'SOME_EVENT',
otherValue: {}
}
exec
useEffect(
() =>
exec(someState, {
SOME_STATE: currentState => {},
}),
[someState],
);
If your state triggers multiple effects you can give an array instead:
useEffect(
() =>
exec(someState, {
SOME_STATE: [function effectA(currentState) {}, function effectB(currentState) {}],
}),
[someState],
);
The effects works like normal React effects, meaning you can return a function which will execute when the state changes:
useEffect(
() =>
exec(someState, {
TIMER_RUNNING: () => {
const id = setInterval(() => dispatch({ type: 'TICK' }), 1000);
return () => clearInterval(id);
},
}),
[someState],
);
transform
const result = transform(state, {
SOME_STATE: currentState => 'foo',
});
Is especially useful with rendering:
return (
<div className="wrapper">
{transform(todos, {
NOT_LOADED: () => 'Not loaded',
LOADING: () => 'Loading...',
LOADED: ({ data }) => (
<ul>
{data.map(todo => (
<li>{todo.title}</li>
))}
</ul>
),
ERROR: ({ error }) => error.message,
})}
</div>
);
Inspirations
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.
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago