DEV Community

Cover image for Loadables - a simple way to load data in React
Late Night Coder
Late Night Coder

Posted on

Loadables - a simple way to load data in React

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

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

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

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

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

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

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

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.

Top comments (0)