Redux - Loading initial state asynchronously
Asked Answered
B

5

23

I'm trying to work out the cleanest way to load the initial state of my Redux stores when it comes from API calls.

I understand that the typical way of providing the initial state is to generate it server-side on page load, and provide it to Redux createStore() as a simple object. However, I'm writing an app that I'm planning on packaging up in Electron and so this doesn't work.

The best that I've been able to come up with so far is to fire an action immediately after creating the store that will go and request the initial state for the store - either one action that retrieves the entire initial state or a number of actions that each retrieve the initial state for one part of the store. This would then mean that my code looks like:

const store = createStore(reducer, Immutable.Map(), middleware);
store.dispatch(loadStateForA());
store.dispatch(loadStateForB());
store.dispatch(loadStateForC());

Whilst this will work, it seems a bit on the crude side and so I'm wondering if there's some better alternative that I'm missing?

Blatman answered 23/5, 2016 at 14:3 Comment(1)
Is it really necessary to load all your data in on startup? You can just load parts depending on which page is visited?Signora
H
13

I also encountered the same problem (also building an electron app). A part of my store has application settings which gets persisted on local file system and I needed to load it asynchronously on application's startup.

This is what I come up with. Being a "newbie" with React/Redux, I am very much interested in knowing the thoughts of the community on my approach and how it can be improved.

I created a method which loads the store asynchronously. This method returns a Promise which contains the store object.

export const configureStoreAsync = () => {
  return new Promise((resolve) => {
    const initialState = initialStoreState;//default initial store state
    try {
        //do some async stuff here to manipulate initial state...like read from local disk etc. 
        //This is again wrapped in its own Promises.
        const store = createStore(rootReducer, initialState, applyMiddleware(thunk));
        resolve(store);
      });
    } catch (error) {
      //To do .... log error!
      const store = createStore(rootReducer, initialState, applyMiddleware(thunk));
      console.log(store.getState());
      resolve(store);
    }
  });
};

Then in my application entry point, here's how I used it:

configureStoreAsync().then(result => {
  const store = result;
  return ReactDOM.render(
    <Provider store={store}>
      <App store={store}/>
    </Provider>,
    document.getElementById('Main'));
});

Like I said, this is my naive attempt at solving this problem and I am sure there must be better ways of handling this problem. I would be very much interested in knowing how this can be improved.

Hairsplitter answered 4/8, 2016 at 6:42 Comment(0)
C
7

As far as I can tell, you have only two options (logically):

  1. Set the initial state after the store is instantiated
  2. Set the initial state when the store is instantiated

Option 1 must be done using an action:

The only way to change the state is to emit an action, an object describing what happened.

One of "Three Principles" in the docs

This is what you've tried, but you think it is crude for some reason.


The alternative is just to call createStore after your asynch request has resolved. One solution has already been posted (by @Gaurav Mantri) using a Promise object, which is a nice approach.

I would recommend against this, since you will likely have multiple modules trying to require or import your store (or store.dispatch, or store.subscribe) before it exists; they would all have to be made to expect Promises. The first method is the most Redux-y.

Composition answered 4/8, 2016 at 7:3 Comment(0)
A
4

My app startup workflow:

  1. Loading spinner in index.html
  2. Ajax to check if user is logged in
  3. On ajax end, render the Root component
  4. Hide the loading spinner

I achieved that by:

  1. Creating the store with a custom middleware that listens for the initial ajax end action and calls a callback once
  2. Dispatching the initial ajax action

root.js

const store = createStore(
    rootReducer,
    applyMiddleware(
        ...,
        actionCallbackOnceMiddleware(INITIAL_AJAX_END, render)
    )
)

function render() {
    ReactDOM.render(
        <Provider store={store}>
            <RootComponent/>
        </Provider>,
        document.getElementById('root')
    )

    document.getElementById('loading').dispatchEvent(new Event('hide'))
}

store.dispatch(initialAjaxAction());

middleware/actionCallbackOnce.js

export default (actionType, callback) => store => next => {
    let called = false;

    return action => {
        next(action);

        if (!called && action.type === actionType) {
            called = true;
            callback();
        }
    }
}

index.html

<div id="loading">
    <span>Loading</span>
    <style type="text/css">...</style>
    <script>
        (function(loading){
            loading.addEventListener('hide', function(){
                loading.remove();
            });
            loading.addEventListener('error', function(){
                loading.querySelector('span').textContent = "Error";
            });
        })(document.getElementById('loading'));
    </script>
</div>

<div id="root"></div>
Adalbertoadalheid answered 31/1, 2018 at 9:47 Comment(0)
A
1

Using extraReducers with createAsyncThunk seems to be the clean way of doing this as explained here

Arcboutant answered 1/8, 2021 at 17:24 Comment(0)
A
1

Using async thunks would give you more control. This approach worked for me. In this example the user has a setting for the UI's theme, and this setting will be persisted to the backend. We can't render the UI until we know this setting.

  1. Add an Async Thunk to a Slice: Here we use createAsyncThunk. Async thunks are actions but with the additional ability to (i) perform an API request, (ii) update the state using results from API request. (I'm assuming here you are using redux slices, if you are not then just add this thunk to your main reducer).

    // ./store/settings.js
    
    import {
      createAsyncThunk,
      createReducer,
    } from '@reduxjs/toolkit';
    import { client } from './api/client';
    
    const initialState = {
      theme: 'light', // can be either 'light', 'dark' or 'system'
    };
    
    const fetchSettings = createAsyncThunk('settings/fetchSettings', async () => {
      const response = await client.fetch('/api/v1/settings');
      // `response` is an object returned from server like: { theme: 'dark' }
      return response; 
    });
    
    const settingsReducer = createReducer(initialState, builder => {
      builder.addCase(fetchSettings.fulfilled, (state, action) => {
        state.theme = action.payload.theme;
      });
    });
    
    export { fetchSettings };
    export default settingsReducer;
    
    
  2. Combine Reducers: With slices your state is divided up and so you'll be bringing all your reducers together into one single reducer (some redux boilerplate has bene replaced with // ...):

    // ./store/index.js
    
    // ...
    // import fooReducer from './store/foo';
    // import barReducer from './store/bar';
    import settingsReducer from './store/settings';
    
    export const store = configureStore({
      reducer: {
        // foo: fooReducer,
        // bar: barReducer,
        settings: settingsReducer,
      },
    });
    
    // ...
    export const { useDispatch, useSelector }
    
  3. Dispatch Thunk: Dispatching the async thunk will perform the API request and update the store. With async thunks you can use await to wait until this is all done. We won't perform the initial render until this is done.

    // ./index.js
    
    import App from './components/App';
    import { store } from './store/index';
    import { fetchSettings } from './store/settings';
    
    async function main() {
      await store.dispatch(fetchSettings());
      root.render(
        <StrictMode>
          <App store={store} />
        </StrictMode>,
      );
    }
    
    main();
    
  4. Render App: The app will use this updated store and render the theme from the backend.

    // ./components/App.js
    import { useSelector } from './store/index';
    
    export default function App({ store }) {
      // read theme from store
      const settings = useSelector(state => state.settings);
      const settingsTheme = settings.theme;
    
      return (
        <Provider store={store}>
          <div>Your app goes here. The theme is ${settingsTheme}</div>
        </Provider>
      );
    }
    
Alp answered 19/2, 2023 at 17:24 Comment(1)
Quick aside about web perf: This is all not bad practice per se, but an important caveat to be aware of is that now you have an initial request that blocks the rendering of your entire app. What that means is that for poor connections (high latency/low throughput), and/or for large payloads, your users are could be waiting a long time for anything to appear. And users seeing a white screen can be very unsatisfying. To mitigate this potential risk, consider minimising what we load here (small payloads, fewer requests), add client-side measurements (web-vitals, lighthouse) and add cachingAlp

© 2022 - 2024 — McMap. All rights reserved.