The real-world app involves data loading via some API and showing UI based on the states of the API. For example, while data is loading, you may show a loader animation, but on error, you may show an error UI. This fairly simple-looking task ends up getting complex super fast and is more difficult to maintain with all the spaghetti code for UI synchronization. So here I propose the loadable pattern to simplify data loading and synchronize the UI with it.
In this example, we are going to load a list of todos. Here we are using react-redux as a state management solution. Below we will see how to create a store and reducer with react-redux. However, you can directly skip to “loadables” if you familiar with react-redux-context store.
Create react-redux context store
Let's start by creating a react-redux-context-store for storing our todos. The following sample is taken from react-redux.
// [filename: todo.store.jsx] import React from 'react' import { Provider, createStoreHook, createDispatchHook, createSelectorHook, from "react-redux"; import { createStore } from "redux"; // reducer for the state import { reducer } from "./store.reducer" // react context store const TodoContext = React.createContext(null) // create redux state selector and dispatch from context export const useTodoStore = createStoreHook(TodoContext) export const useTodoDispatch = createDispatchHook(TodoContext) export const useTodoSelector = createSelectorHook(TodoContext) // create redux store from the reducer const todoStore = createStore(reducer) // create store provider wrap subtree export function TodoStoreProvider({ children }) { return ( <Provider context={TodoContext} store={todoStore}> {children} </Provider> ) }
After creating a store provider we are going to create store.reducer.js
where we define the reducer and actions for the store.
// [filename: todo.reducer.js] export const loadNext = () => ({ type: 'load_next' }); export const addTodos = ({ todos, total }) => ({ type: 'add_todos', payload: { todos, total } }); export const setLoading = (loading) => ({ type: 'set_loading', payload: { loading } }); const InitState = { status: 'idle', // idle | pending | resolve | reject todos: [], total: 0, skip: 0, limit: 10 }; export const reducer = (state = InitState, action) => { switch (action.type) { case 'load_next': { if (state.todos.length < state.total && state.status !== 'pending') { return { ...state, status: 'pending' }; } return state; } case 'add_todos': { return { ...state, status: 'resolve', todos: [...state.todos, ...action.payload.todos], total: state.total + action.payload.todos.length }; } case 'set_loading': { return { ...state, status: action.payload.loading }; } default: { return state; } } };
Loadable
Loadables are react components that wrap all data loading logic in it and update the store.
// [filename: App.js] const App = () => ( <div> <TodoStoreProvider> {/* Loadable holds all data loading logic*/} <TodoLoadable> {/* Render todos */} </TodoLoadable> </TodoStoreProvider> </div> );
Now let's create a loadable:
// [filename: Todo.loadable.js] function TodoLoadable(props) { // react-redux state slice selector const skip = useTodoSelector((state) => state.skip); const limit = useTodoSelector((state) => state.limit); const todoDispatch = useTodoDispatch(); // load data useEffect(() => { todoDispatch(setLoading('pending')); api({ skip, limit }) .then((res) => todoDispatch({ todos: res.todos, total: res.total })) .catch((e) => todoDispatch(setLoading('reject'))); }, [skip, limit]); // render child return <>{props.children}</> }
The point to note here is that the loading logic is completely placed inside the loadable and the children can utilize the store to sync UI state accordingly. IsVisible
is a utility component that can be used to render things conditionally.
// [filename: IsVisible.utility.jsx] function IsVisible({ visible, unmountOnExit, ...props }) { if (unmountOnExit && !visible) { return null; } return <div {...props} style={{ ...props.style, display: visible ? 'flex' : 'none' }} /> }
We can use the IsVisible
utility component to create state synced UI.
// [filename: Todo.jsx] const Error = () => <div><h1>Error</h1></div>; const Loader = () => <CircularProgress size="small" /> const Todos = () => { const todos = useTodoSelector((state) => state.todos); return <div>{todos.map((todo) => <h1>{todo}</h1>)}</div> } function IsErrorVisible(props) { const isError = useTodoSelector((state) => state.status === 'reject'); return <IsVisible {...props} visible={isError} /> } ....more IsVisible for all API status 'reject' | 'resolve' | 'pending' | 'idle'
Now with the help of this IsVisible
, we can render UI according to the state of API.
// [filename: App.js] const App = () => ( <div> <TodoStoreProvider> {/* Loadable holds all data loading logic*/} <TodoLoadable> <IsErrorVisible><ErrorUI /></IsErrorVisible> <IsTodoVisible><Todos /></IsTodoVisible> <IsLoaderVisible><Loader /></IsLoaderVisible> </TodoLoadable> </TodoStoreProvider> </div> );
This is how loadable
along with IsVisible
utility makes it super easy to load data in react and make a code simple to write and understand. Here is a link to demo Codesandbox.