Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to hydrate the store on first SSR page load with sagas without using getInitialProps #336

Closed
mercteil opened this issue Mar 26, 2021 · 6 comments

Comments

@mercteil
Copy link

mercteil commented Mar 26, 2021

What I look forward to is to get rid of getInitialProps in _app.js in order to remove my custom server and use static opt.

There is one single problem left for me to deal with and I am kind of stuck. I use Next + Redux + Saga. Usually what I was doing is to compose getInitialProps from pages to _app.js and then render everything as it was normal before the fancy getServer and getStaticProps. I now figured everything out to get rid of getInitialProps in _app but here is the problem:

_app.js

// In my _app.js I have a custom HoC that wraps my App component

const MyApp = ({ Component, nonce, pageProps = {} }) => { ... }

MyApp.getInitialProps = async ({ Component, ctx }) => { ... };

export default withReduxSaga(appWithTranslation(MyApp));

withReduxSaga HoC

const withReduxSaga = (AppComponent) => {
  const WithReduxSaga = ({ store, ...restProps }) => {
    return (
      <Provider {...{ store }}>
        <AppComponent {...restProps} />
      </Provider>
    );
  };



  WithReduxSaga.getInitialProps = async (pipedProps) => {
    const { isServer, store, req } = pipedProps.ctx;

    if (isServer) {
      const {
        headers: { 'user-agent': ua },
      } = req;


        // Here is the juicy part:
        // I dispatch (store.execTasks) some basic server side actions on first page load in order to hydrate my store with some 
        // general pageProps in order to SSR the proper page to the client
        // "proper page" means for example:
        // Check whether the client is logged in => SSR the clients prices 
        // Set some layout params 
        // Set the right currency 
        
        // All these actions need to be executed only once prior first page load render on the client's side
        // UX would be weird if you had to reload the prices after the pages already loaded
        // SEO would not properly work if you load the prices on the client's side
        // I preset some layout variables in order to avoid elements flashing around

      store.execTasks([
        ...[CHANGE_CURRENCY, CHECK_AUTHORIZATION].map(prepareCookieActions(pipedProps.ctx)),
        currenciesListRequested(),
        addScreenResolution(isMobile({ ua, tablet: true }) ? [0, 0] : [HEADER_COLLAPSE_WIDTH, 0]),
      ]);
    }

    // Accordingly get all the page props via _app.js afterwards
     
    const pageProps = await AppComponent.getInitialProps?.(pipedProps);

    // Stop the sagas if not already stopped

    if (isServer) await store.stopSagas();

    return pageProps;
  };

  return withRedux(createStore)(WithReduxSaga);
};

export default withReduxSaga;

Ultimately the question is:

How is it possible to hydrate the store within SSR on the first page load without using getInitialProps in the _app.js?

@mercteil
Copy link
Author

mercteil commented Apr 5, 2021

Yup, there is simply no way, because there shall not be getInitialProps.

This is by design of Next.js as far as I understood.

There is a need of lifecycle methods within _app.js (not getInitialProps).

EDIT:

Actually there is a way, will follow up asap

@kirill-konshin
Copy link
Owner

@mercteil any findings?

@kirill-konshin
Copy link
Owner

kirill-konshin commented Apr 17, 2021

How is it possible to hydrate the store within SSR on the first page load without using getInitialProps in the _app.js?

You can use per-page approach. W/o wrapping the _app. App itself may not call any lifecycle methods, just provide the top level wrapper. See examples.

@Huespal
Copy link

Huespal commented Jun 10, 2021

I also has this question. Same stack NextJS + Redux + Saga, per page approach.
Following this example.

saga

export function* getUser() {
  const response = yield call(fetch, '/user'); // I've shortened this. Just a fetch to the API
  if (!response?.error) {
    yield put(setUser(response)); // A simple set to the user reducer.
    yield put(END);
  }
}

page

export const getServerSideProps = wrapper.getServerSideProps(async ({ store }) => {
  store.dispatch(fetchUser()); // Dispatch an action to call the saga
  await (store as any).sagaTask.toPromise();
});

const UserPage = () => {
    const user = useSelector((state) => state.user);

    return <div>{user.name}</div> 
}

UserPage div content is always empty :/

If I console log the actions to the reducer, I can see the data in the reducer (on SSR) but after a second gets undefined:

Log for a user page load:

action.type:  SET_USER
action.payload.name:  John -> That's the user name from the action 
state.user.name:  
action.type:  @@redux-saga/CHANNEL_END
action.payload.name:  undefined
state.user.name:  John -> That's the user name into the reducer :)
action.type:  @@redux/INITg.r.j.o.u.n
action.payload.name:  undefined
state.user.name:  undefined -> Where is the user name now?
action.type:  __NEXT_REDUX_WRAPPER_HYDRATE__
action.payload.name:  undefined
state.user.name:  
action.type:  __NEXT_REDUX_WRAPPER_HYDRATE__
action.user.name:  undefined
state.user.name:

Hydrate

const rootReducer = (state: any, action: Action<any>) => {
  console.log('action.type: ', action.type);
  console.log('action.payload.name: ', action.payload?.name);
  
  if (action.type === HYDRATE && action.payload !== undefined) {
    const isUserOnClient = state.user?.name !== '';
    
    state = {
      ...state,
      ...action.payload,
      user: isUserOnClient ? state.user : action.payload.user
    };
  }
  
  console.log('state.user.name: ', state?.user?.name);
  
  return appReducer(state, action); // Just a combineReducers method
};

Maybe I am misinterpreting some part of it?
How can I set data into the reducer in SSR to use it in client as always?

Thank you

@dimisus
Copy link

dimisus commented Jun 11, 2021

EDIT: next-redux-wrapper v7.xx

One will have to curry the store to make it work with v7. Meaning everywhere you call gSSP(context) you will have to call gSSP(store, context)... and in the page gSSP you will have to accept the store as a curried argument like curry(async (story, context) => {...})


using next-redux-wrapper 6.xx

Sorry for the late reply. Totally forgot.

There is a relatively simple way to solve the problem by composing getServerSideProps and use them across all pages where needed. First of all my actual problem was the initial hydration of the store only when the user requests the initial application load. All client side navigation do not have to process the same action every time. And of course I need this data prior the initial page load, meaning if the request has i.e. a token the user should be loaded on the server and hydrated/served to the client initially.

My solution:

  1. Since the newer Nextjs releases you can use getServerSideProps (=gSSP) or getStaticProps (=gSP) per page basis.
  2. Nextjs does neither allow to use gSSP nor gSP in _app.js but only the "legacy" getInitialProps lifecycle method.
  3. Since gSSP is a function it is composable

Lets say you have an application which would only use getServerSideProps across all the pages except some static pages without state like 404.js or 500.js error pages you can generate a general Wrapper for the gSSPs and compose/pipe logic on every request.

Therefore I created this wrapper which lets me compose any amount of separated gSSP functions. The wrapper at the end receives the original gSSP per page and waits for the context which is provided on page load/navigation.

import { gSSPWithRedux } from '@client/store';
import { compose, curry } from 'lodash/fp';
import gSSPWithCSP from '../gSSPWithCSP';
import gSSPWithCSRFlag from '../gSSPWithCSRFlag';
import gSSPWithReduxSaga from '../gSSPWithReduxSaga';
import gSSPWithSSRProps from '../gSSPWithSSRProps';

const gSSPWrapper = curry((gSSP, context) => {
  if (gSSP && context) {
    // !!IMPORTANT!! The order of execution after providing gSSP is top down
    // which means gSSPWithRedux is executed first and decorates the context with the store
    // => order of execution is crucial!!!
    const wrappedGSSPs = compose(
      gSSPWithRedux,
      gSSPWithCSRFlag,
      gSSPWithReduxSaga,
      .... any other function
    )(gSSP);

    return Promise.resolve(wrappedGSSPs(context));
  }

  throw Error('Either context or gSSP is not provided');
});

export default gSSPWrapper;

The with redux wrapper is basically this package:

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware, { END } from 'redux-saga';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import { createWrapper } from 'next-redux-wrapper';
...

const bindMiddleware = (middlewares) => {
  if (isDev) {
    return composeWithDevTools(applyMiddleware(...middlewares));
  }

  return applyMiddleware(...middlewares);
};

const initStore = () => {
  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(rootReducer, bindMiddleware([sagaMiddleware]));

  store.execTasks = (tasks = []) => {
    return ((isArray(tasks) && tasks) || [tasks]).filter(Boolean).forEach(store.dispatch);
  };

  store.stopSagas = async () => {
    if (store.currentSagas) {
      store.dispatch(END);
      await store.currentSagas.toPromise();
    }
  };

  store.runSagas = () => {
    if (!store.currentSagas) {
      store.currentSagas = sagaMiddleware.run(rootSaga);
    }
  };

  store.runSagas();

  return store;
};

export const wrapper = createWrapper(initStore, { debug: false });

export const gSSPWithRedux = wrapper.getServerSideProps;

The withCSR function decorates the context with a flag that indicates whether the client is navigating or whether it is the initial application load. This step is curcial for the further logic to work and also a pretty nice feature to separate logic which is not needed for client navigation. You can read about it here (also copied from there): vercel/next.js#13910

CSR decodes to Client Side Request (or Client Side Navigation with an R)

import { curry } from 'lodash/fp';

const gSSPWithCSRFlag = curry((gSSP, context) => {
  if (gSSP && context) {
    // Check and set a flag to context that checks if the request
    // is coming from client side routing/navigation
   
    context.isCSR = Boolean(context.req.url.startsWith('/_next'));

    return Promise.resolve(gSSP(context));
  }

  throw Error('Either context or gSSP is not provided');
});

export default gSSPWithCSRFlag;

And finally the solution of the initial problem:

const gSSPWithReduxSaga = curry((gSSP, context) => {
  if (gSSP && context?.store) {
    // IMPORTANT!!
    // store should already be within context, watch out for the order of gSSP wrapping
    // means next-redux-wrapper puts the store in context and should do it prior this HoC
    // !!IMPORTANT!!
    // In order to execute server side logic on page load/request
    // you will have to fire sagas every time the server is requested
    // (prior hydration) on the server side.
    // This does not work with getStaticProps because there is no req object in context
    // which means there is no token etc. provided (logically since not known prior app build)
    // Step 1: Execute server side sagas whenever a req is incoming, store is created and root sagas are started
    // Step 2: Every app component has to implement getServerSideProps (store is provided as an argument by next-redux-wrapper)
    // Step 3: When you need to execute sagas for a page do it within getServerSideProps with store.execTasks(...)
    // Step 4: !!!Always!!! stop the sagas in getServerSideProps by await store.stopSagas(). Sagas are stopped here.
    //         Generally if you do not need to interact with the store (i.e. store.getState() in gSSP)
    //         you can let the wrapper stop them here. Otherwise do it manually in gSSP prior store interaction.

    if (!context.isCSR) {
      // isCSR indicates whether it is a consequent client req or an initial server req

      // HERE
      // THE INITIAL PROBLEM OF THIS THREAD IS SOLVED BY EXECUTING GENERAL SSR LOGIC ONCE
      // HERE
       context.store.execTasks([...)]);
    }

    return Promise.resolve(gSSP(context)).then(async (pipedProps) => {
      await context.store.stopSagas();

      return pipedProps;
    });
  }

  throw Error('Either context or gSSP is not provided');
});

export default gSSPWithReduxSaga;

Everything else is super easy. You write some page specific gSSP function and let us say some gSSP_for_SEO which works with the props to prepare SEO and at the end you wrap it with the wrapper described above.

export const getServerSideProps = gSSPWrapper(withGSSPSEOData(async ({locale, req, res, store, isCSR,...}) => {
  ...
}));

And do not forget to hydrate the store...

import { createReducer } from 'redux-act';
import { HYDRATE } from 'next-redux-wrapper';

export default createReducer(
  {
    [some action from redux]: (state, payload) => ({}),

    [some other action from redux]: (state) => ({ ...state}),

    [HYDRATE]: (state, payload) => {
       // compare state and payload and decide what goes inside the store
    },
  },
  initialState
);

@kirill-konshin
Copy link
Owner

There was no activity in this issue for quite a long time, closing. If you feel it needs to be reopened, please reach out to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants