rt2zz / redux-persist

persist and rehydrate a redux store
MIT License
12.95k stars 865 forks source link

Delay Render Until Rehydration Complete #126

Closed moughxyz closed 8 years ago

moughxyz commented 8 years ago

In the recipe for delay render, it's pretty straightforward if persistStore is called in the same file that you're rendering in. However, my rendering happens in a different component. Is there a straight forward way to notify a separate component that rehydration has completed?

moughxyz commented 8 years ago

I ended up doing this:

persistStore(store, {}, () => {
  store.dispatch(rehydrationComplete())
})

and then subscribing to this action in the component, and re-rendering then. Do you see any issues with going this route?

rt2zz commented 8 years ago

@mobitar ya that works, you can alternatively handle the regular rehydrate action to set a rehydrated: true value in your redux state. e.g.

import { REHYDRATE } from 'redux-persist/constants'

function myReducer(state, action){
//...
case REHYDRATE:
   return {...state, ...action.payload.myReducer, rehydrated: true}
//...
IanVS commented 8 years ago

you can alternatively handle the regular rehydrate action to set a rehydrated: true value in your redux state.

How would one then prevent that rehydrated key from being persisted along with the rest of the store, so that on page refresh it's reset back to false until REHYDRATE happens again?

IanVS commented 8 years ago

Hm, nevermind. It seems that adding rehydrated: false to the default state of the reducer is enough.

tomprogers commented 8 years ago

@rt2zz

I've been using the same strategy: I defined initialState.storeReady = false, then implemented my own custom reducer for persist/REHYDRATE that explicitly writes storeReady: true to app state. My default route (I'm using react-native-router-flux) waits until it receives this.props.storeReady === true, and then navigates to the first screen of the app. All of that seems to work great.

However, I'm trying to reset to their defaults several additional store properties that I don't want to persist across sessions, and for whatever reason only some of them are effective. That is, some store properties are successfully overwritten with hardcoded values and downstream components see the default values, and other store properties retain their persisted values and downstream components see the undesirable values from the last session. I don't understand how this is even possible, and I suspect some kind of bug in either react-redux or redux-persist.

Here's my reducer:

// at top
import { initialState } from './store';

// ...
'persist/REHYDRATE': (state, action) => {
    return Object.assign({}, state, action.payload, {
        storeIsReady: true,

        // -- here, reset to their initial values any store keys we don't wish to persist across sessions -- //

        // can change session to session (e.g. iOS update)
        deviceToken: initialState.deviceToken,

        // "names" vs "hex"; the former should be selected by default
        colorFormat: initialState.colorFormat,

        // a list of colors, in either name or hex format
        colors: initialState.colors,

        // boolean; pushing a button sets it to true, and I want to reset it to false every session -- resetting it fails, and all components see this prop as true once redux-persist is done
        warnAboutIncomingKittens: initialState.warnAboutIncomingKittens
    });
}

What is bizarre to me is that some of those lines are effective, and others are not. Consider colors for example. The default set of colors is the ROYGBIV color names. There's a button in the app that makes an API call to fetch additional colors. When that call returns, a reducer replaces the short, default color list with the much-longer list from the web. If I comment out the color-reset code in the REHYDRATE reducer, the long list is waiting for me when I reboot the app. If I let the color-reset code execute, the short list is waiting for me when I reboot the app.

I've confirmed via logging that the state object I'm returning has the desired warnAboutIncomingKittens: false, so I'm convinced it's not a logical error inside this reducer.

Any help trapping this bug is appreciated. It's really got me stuck, and if I can't work past it, I'll have to remove redux-persist from our application stack for the time being. I don't want to go back to marshalling my own data into AsyncStorage. 8)

tomprogers commented 8 years ago

Note: it started working correctly when I changed warnAboutIncomingKittens from a scalar to a one-element array that holds the same data. That should be unnecessary, and suggests a bug in the way state updates are being handled in the middleware. Devs shouldn't have to wrap scalars in unwanted objects to get the correct behavior.

rt2zz commented 8 years ago

@tomprogers interesting. I think I understand what is happening but can you clarify: the reducer from your code sample is the top level reducer (i.e. you are not using combineReducers)?

I suspect the logic in autoRehydrate relies on certain assumptions that combineReducer provides (like initial state being set from initialization). I will need to look into this more.

tomprogers commented 8 years ago

@rt2zz you're correct, this is the top-level reducer, and I'm not using combineReducers. I believe my root reducer does return the initial state at first call:

// my store file is 10% middleware setup, and 90% POJO describing initialState
import { initialState } from './store';

export default function(oldState = initialState, action) {
    let operation = Reducers[action.type];
    if(!operation) return oldState;

    return operation(clone(oldState), action);
};

// just above that...
let Reducers = {
    'persist/REHYDRATE': (state, action) => {
        return Object.assign({}, state, action.payload, {
            storeIsReady: true,

            // -- here, reset to their initial values any store keys we don't wish to persist across sessions -- //

            // can change session to session (e.g. iOS update)
            deviceToken: initialState.deviceToken,

            // "names" vs "hex"; the former should be selected by default
            colorFormat: initialState.colorFormat,

            // a list of colors, in either name or hex format
            colors: initialState.colors,

            // boolean; pushing a button sets it to true, and I want to reset it to false every session -- resetting it fails, and all components see this prop as true once redux-persist is done
            warnAboutIncomingKittens: initialState.warnAboutIncomingKittens
        });
    }
};
rt2zz commented 8 years ago

@tomprogers we have since made some improvements to the assumptions made in the stateReconciler in autoRehydrate. I suspect we should be 👍 now

Andreyco commented 7 years ago

Here is my approach to delay render until store is rehydrated.

I am not keeping track of rehydrated flag or anything similar. Simply I don't render until data is loaded.

// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux';
import { autoRehydrate, persistStore } from 'redux-persist';
import { localStorage } from 'redux-persist/storages';
import { rootReducer } from '../reducers';

export default function configureStore() {
  // use desired middlewares
  const middlewares = [];

  return new Promise((resolve, reject) => {
    try {
      const store = createStore(
        rootReducer,
        undefined,
        compose(
          autoRehydrate(),
          applyMiddleware(...middlewares),
        ),
      );

      persistStore(
        store,
        { storage: localStorage },
        () => resolve(store)
      );
    } catch (e) {
      reject(e);
    }
  });
}

// application entry file
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import Router from './routes';
import configureStore from './configureStore';

async function init() {
  const store = await configureStore();
  ReactDOM.render(
    <Provider store={store}>
      <Router />
    </Provider>,
    document.getElementById('root')
  );
}

init();
joanrodriguez commented 7 years ago

Great trick, worked a charm for me.

Couple of remarks:

ghost commented 7 years ago

@Andreyco Any idea on how to adapt this for React Native? I tried and it complains when I wrap AppRegistry in an async function.

sidmitra commented 7 years ago

Is this the preferred way to do this now?

https://github.com/rt2zz/redux-persist/blob/master/docs/recipes.md#delay-render-until-rehydration-complete

Andreyco commented 7 years ago

@sidmitra depends on your use case, of course. If you need more functionality (redux reducers, sagas, ...), then probably not.

@joanrodriguez Thanks for you input, edited the snippet. Regarding exporting store for reuse, it's already "exported". Not directly, since it's resolved async and not know immediately. Promise either resolves with store of fails. In awaiting call site you can do whatever with resolved store.

@RobertSheaO having configureStore.js above, create following component which would hold resolved store. When store is initiated, render app wrapped in redux provider. Hope, there are no mistakes in snippet, expect missing imports ;)

import React, { Component } from 'react'
import { Provider } from 'react-redux';
import configureStore from './configureStore';

class Bootloader extends Component {
  state = {
    store: null
  }

  async componentWillMount () {
    const store = await configureStore();
    this.setState({ store });
  }

  render () {
    if (this.state.store === null) {
      return (
        <Text>
          Booting...
        </Text>
      )
    }

    return (
      <Provider store={this.state.store}>
        <App />
      </Provider>
    )
  }
}

export default Bootloader
leoskyrocker commented 7 years ago

@Andreyco I don't think the code solves what the problem was: waiting the rehydration to be done in a component which doesn't configure the store. For example, if you want to wait in a page component before rendering anything. Reading from the redux state solves the problem.

buckhx commented 7 years ago

How is this pattern covered in v5? I'm currently subscribing to the store to monitor when _persist.rehydrated is true, but it feels pretty hacky.

    componentWillMount() {
        persistStore(store);
        store.subscribe(() => {
            const { loading } = this.state;
            if (loading && store.getState()._persist.rehydrated) {
                this.setState({ loading: false }); 
            }   
        }); 
    } 
Andreyco commented 7 years ago

Here is my solution for v5. Very similar to yours.

store.js

// @flow

import { AsyncStorage } from 'react-native';
import { applyMiddleware, compose, createStore } from 'redux';
import { persistStore, persistReducer } from 'redux-persist';
import createSagaMiddleware from 'redux-saga';

import rootReducer from './reducers';
import rootSaga from './sagas';

export function initStore() {
  const sagaMiddleware = createSagaMiddleware();

  const persistedReducer = persistReducer(
    {
      key: 'redux-state',
      version: 1,
      storage: AsyncStorage,
      whitelist: ['user'],
    },
    rootReducer
  );

  const store = createStore(
    persistedReducer,
    rootReducer(undefined, {}),
    compose(applyMiddleware(sagaMiddleware))
  );

  persistStore(store);

  sagaMiddleware.run(rootSaga);

  return store;
}

Bootloader.js - application entry point. Renders Bootscreen while loading, Application otherwise

// @flow

import React from 'react';
import { type Persistor } from 'redux-persist';
import { AppRegistry } from 'react-native';
import { Provider } from 'react-redux';

import { initStore } from './redux/store';
import { BootScreen } from './screens';
import Application from './Application';
import { selectApplicationIsReady } from './redux/selectors';

type Props = {
  persistor: Persistor,
  render: (persistor: Persistor, ready: boolean) => React$Element<*>,
};

type State = {
  ready: boolean,
};

export default class Bootloader extends React.PureComponent<*, Props, State> {
  state = {
    ready: false,
  };

  unsubscribeFromPersistorUpdates: ?() => void;

  componentDidMount() {
    const { persistor } = this.props;
    this.unsubscribeFromPersistorUpdates = persistor.subscribe(() => {
      if (selectApplicationIsReady(persistor.getState())) {
        this.setState({ ready: true });
        this.unsubscribeFromPersistorUpdates &&
          this.unsubscribeFromPersistorUpdates();
      }
    });
  }

  componentWillUnmount() {
    this.unsubscribeFromPersistorUpdates &&
      this.unsubscribeFromPersistorUpdates();
  }

  render() {
    return this.props.render(this.props.persistor, this.state.ready);
  }
}

AppRegistry.registerComponent('app', () => () =>
  <Bootloader
    persistor={initStore()}
    render={(store, ready) =>
      ready
        ? <Provider store={store}>
            <Application />
          </Provider>
        : <BootScreen />}
  />
);

Application.js - application itself

// @flow

import React from 'react';
import { connect, type Connector } from 'react-redux';

import { API } from './services';
import { selectAccessToken, selectUser } from './redux/selectors';
import { LoginRouter, MainRouter } from './routers';
import type { Store, User } from './Types';

type Props = {
  accessToken: ?string,
  user: ?User,
};

class Application extends React.PureComponent<void, Props, void> {
  componentDidMount() {
    API._transport.interceptors.request.use(config => {
      config.headers = {
        Authorization: this.props.accessToken
          ? `Bearer ${this.props.accessToken}`
          : '',
        ...config.headers,
      };
      return config;
    });
  }

  render = () => {
    const { user } = this.props;
    return user ? <MainRouter screenProps={{ user }} /> : <LoginRouter />;
  };
}

export default (connect((state: Store) => ({
  accessToken: selectAccessToken(state),
  user: selectUser(state),
})): Connector<{}, Props>)(Application);
MichaelRazum commented 7 years ago

I had a Issue with some of the solutions above: https://github.com/rt2zz/redux-persist/issues/415

Ended up with very simple code. In my case it was a banner, so in this case its ok to delay for a second. I think its a simple solution that doesn't make the code and the store setup more complicated then it should be.

    constructor(props) {
        super(props);
        this.state = { rehydrated: false }

    }

    componentWillMount(){
        setTimeout(() => {
            this.setState({ rehydrated: true }); }, 1000);
    }
stvkoch commented 7 years ago

Hi, You can create a personal rootReducer to inject when REHYDRATE is completed

import { REHYDRATE } from 'redux-persist/constants';

const reducers = combineReducers({
...
});

const createRehydrateRootReducer = reducer => (state, action) => {
  if (action.type === REHYDRATE) {
    return { ...state, ...action.payload, rehidrate: true };
  }
  return reducer(state, action);
};

export default createRehydrateRootReducer(reducers);
harrisrobin commented 7 years ago

Or, if you are using it with seamless-immutable and redux-sauce, this worked for me :

import Immutable from "seamless-immutable"
import { createReducer, createActions } from "reduxsauce"
import { REHYDRATE } from "redux-persist/constants"

export const INITIAL_STATE = Immutable({
  complete: false
})

const rehydrationCompleteSuccess = state => state.merge({ complete: true })

export const reducer = createReducer(INITIAL_STATE, {
  [REHYDRATE]: rehydrationCompleteSuccess
})

EDIT:

And in my app.js, if using react-router 4 I like to do this:

import React from "react"
import { compose } from "recompose"
import { connect } from "react-redux"
import { withRouter, Route } from "react-router-dom"
import Home from "./containers/Home"
import Landing from "./containers/Landing"

const App = props =>
  <main>
    {props.rehydrateComplete
      ? <div>
          <Route exact path="/" component={Landing} />
          <Route path="/dashboard" component={Home} />
        </div>
      : null}
  </main>

const mapStateToProps = state => ({
  rehydrateComplete: state.rehydration.complete
})

const mapDispatchToProps = () => ({})

export default compose(
  withRouter,
  connect(mapStateToProps, mapDispatchToProps)
)(App)
keblodev commented 7 years ago

Just gonna put IMFO which nobody asked for here, But this Rehydration... is bad. The fact that you need to jump through all sorts of hoops because of this thing and WAIT for store to load -> is nonsense. It should happen on @@INIT in the first place. Switching back to redux-localstorage because of this... feature

rt2zz commented 7 years ago

@ronanamsterdam thats not unreasonable but Ill share some additional context:

  1. in order to support async storage engines we cannot rehydrate as part of initial state. If you only want to use localStorage, then this simplifies the problem which is 👍
  2. redux-persist@5 changes the model a bit, and while it still uses an async REHYDRATE action, there is a PersistGate component that handles the work of delaying render so integration is much simpler (although still not as simple as using sync localStorage).
jasan-s commented 6 years ago

@Andreyco I can't seem to get registerServiceWorker()to work with your solution. I'm using createReactApp.

shamseerahammedm commented 4 years ago

as the documentation suggests -> If you are using react, wrap your root component with PersistGate. This delays the rendering of your app's UI until your persisted state has been retrieved and saved to redux. NOTE the PersistGate loading prop can be null, or any react instance, e.g. loading={}

import { PersistGate } from 'redux-persist/integration/react'

// ... normal setup, create store and persistor, import components etc.

const App = () => {
  return (
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <RootComponent />
      </PersistGate>
    </Provider>
  );
};

just in case if anyones stuck at implementing private route and ur getting redirected to login screen coz of state values returning null at initial render, dont forget to wrap your rootcomponent with PersistGate