[공지사항] 푸샤 깃허브 블로그 업데이트 사항

[Redux Wrapper for Next.js] (https://github.com/kirill-konshin/next-redux-wrapper)

Redux Wrapper for Next.js?

  • 리액트에서 Redux 사용시에는 Store가 하나만 존재하지만, Next.js에서 사용자(User)가 요청(Request) 보낼 때마다 Redux store가 새로 생성되며, Next.js가 제공하는 getInitialProps, getServberSideProps 등에서 리덕스 스토어 접근해야하는데, next-redux-wrapper가 이를 간편헤 해준다.

Redux Wrapper for Next.js

npm version Build status Coverage Status

A HOC that brings Next.js and Redux together

:warning: 해당 라이브러리는 현재 Next.js 9.3 이상에서만 작동합니다. 만약 Next.js 6-9 버전을 사용하고 있다면 해당 라이브러리 버전 3 ~ 5 버전을 사용하세요. branches. 그렇지 않다면 Next.js 버전 업그레이드를 권장합니다.

내용:

왜?

정적인 앱에서 Redux를 사용하는 것은 다소 간단합니다. 모든 페이지에서 제공되는 단일 Redux 저장소(Store)만 만들면 되기 때문이빈다.

그러나 Next.js 로 정적인 사이트를 만들거나 혹은 Redux 연결 구성요스를 렌더링하기 위해서버에 다른 저장소 인스턴스가 필요하기 때문에 상황이 복잡해집니다.

또한 페이지의 getInitialProps와 작동하는동안 Redux의 Store(저장소)와 연결(access)가 필요할 수 있습니다.

next-redux-wrapper가 유용한 이유가 여기 있습니다. 해당 라이브러리는 자동적으로 저장소 인스턴스를 생성하고모두 동일한 상태를 가질 수 있도록 해줍니다.

또한 개별 페이지 레벨에서 App.getInitialProps 같이 (pages/_app 사용한다면) 복잡한 경우에도 getStaticProps 혹은 getServerSideProps를 함께 허용해줍니다.

라이브러리는 Next.js 라이플 사이클 바업에 상관없이 Store를 사용하게 해줍니다.

Next.js 예제에서 https://github.com/vercel/next.js/blob/canary/예제에서s/with-redux/store.js#L55 store 가 네비게이션을 대체합니다. Redux는 https://codesandbox.io/s/redux-store-change-kzs8q 가 교체되는 경우 메모된 선택기(createSelectorrecompose)를 사용 하여 구성 요소를 다시 렌더링합니다. 이는 심지어 변하지 않은 모든 것까지도 동일하게 해당 라이브러리는 유지되도록 해줍니다.

설치

npm install next-redux-wrapper react-redux --save

next-redux-wrapperreact-redux 라이브러리에 종속(의존)되어있습니다.

사용법

예시: https://codesandbox.io/s/next-redux-wrapper-demo-7n2t5.

모든 예시들은 Typescript로 쓰여졌습니다. 만약 Javascript만 사용하고 있다면 타입을 유형을 선언하세요. 이 예시들은 바닐라 Redux 로 되어있으며, 만약 Redux Toolkit을 사용하고 있다면 dedicated example 참조하세요.

Next.js 는 여러 데이터를 fetching 해주는 메카니즘을 가지고 있으며, 이 라이브러리는 어느 것이나 붙여서 사용할 수 있습니다. 그러나 먼저 몇 가지 일반적인 코드를 작성해야합니다.

리듀서(reducer)에는 반드시 HYDRATE 액션 핸들러(action handler)가 있어야 합니다. HYDRATE 액션 핸들러는 반드시 기존 상태(있는 경우) 위에 있는 수화(hydrated) 상태를 적절하게 조정해야 합니다 이 동작은 해당 라이브러리 6 버전에 추가되었습니다. 특별한 액션에 대해서 조금 있다가 자세하게 이야기해보도록 하겠습니다.

다음와 같은 파일을 만들어주세요. store.ts:

// store.ts

import { createStore, AnyAction, Store } from "redux";
import { createWrapper, Context, HYDRATE } from "next-redux-wrapper";

export interface State {
  tick: string;
}

// reducer 생성해주세요.
const reducer = (state: State = { tick: "init" }, action: AnyAction) => {
  switch (action.type) {
    case HYDRATE:
      // Attention! This will overwrite client state! Real apps should use proper reconciliation.
      return { ...state, ...action.payload };
    case "TICK":
      return { ...state, tick: action.payload };
    default:
      return state;
  }
};

// makeStore function(함수)를 생성해주세요.
const makeStore = (context: Context) => createStore(reducer);

// export an assembled wrapper
export const wrapper = createWrapper<Store<State>>(makeStore, { debug: true });
Same code in JavaScript (without types) ```js // store.js import { createStore } from "redux"; import { createWrapper, HYDRATE } from "next-redux-wrapper"; // create your reducer const reducer = (state = { tick: "init" }, action) => { switch (action.type) { case HYDRATE: return { ...state, ...action.payload }; case "TICK": return { ...state, tick: action.payload }; default: return state; } }; // create a makeStore function const makeStore = (context) => createStore(reducer); // export an assembled wrapper export const wrapper = createWrapper(makeStore, { debug: true }); ```

It is highly recommended to use pages/_app to wrap all pages at once, otherwise due to potential race conditions you may get Cannot update component while rendering another component:

import React, { FC } from "react";
import { AppProps } from "next/app";
import { wrapper } from "../components/store";

const WrappedApp: FC<AppProps> = ({ Component, pageProps }) => (
  <Component {...pageProps} />
);

export default wrapper.withRedux(WrappedApp);
Same code in JavaScript (without types) ```js import React from "react"; import { wrapper } from "../components/store"; const MyApp = ({ Component, pageProps }) => <Component {...pageProps} />; export default wrapper.withRedux(MyApp); ```

:warning: Next.js provides generic getInitialProps when using class MyApp extends App which will be picked up by wrapper, so you must not extend App as you’ll be opted out of Automatic Static Optimization: https://err.sh/next.js/opt-out-auto-static-optimization. Just export a regular Functional Component as in the example above.

State reconciliation during hydration

Each time when pages that have getStaticProps or getServerSideProps are opened by user the HYDRATE action will be dispatched. This may happen during initial page load and during regular page navigation. The payload of this action will contain the state at the moment of static generation or server side rendering, so your reducer must merge it with existing client state properly.

Simplest way is to use server and client state separation.

Another way is to use https://github.com/benjamine/jsondiffpatch to analyze diff and apply it properly:

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

// create your reducer
const reducer = (state = {tick: 'init'}, action) => {
    switch (action.type) {
        case HYDRATE:
            const stateDiff = diff(state, action.payload) as any;
            const wasBumpedOnClient = stateDiff?.page?.[0]?.endsWith('X'); // or any other criteria
            return {
                ...state,
                ...action.payload,
                page: wasBumpedOnClient ? state.page : action.payload.page, // keep existing state or use hydrated
            };
        case 'TICK':
            return {...state, tick: action.payload};
        default:
            return state;
    }
};

Or like this (from with-redux-wrapper example in Next.js repo):

const reducer = (state, action) => {
  if (action.type === HYDRATE) {
    const nextState = {
      ...state, // use previous state
      ...action.payload, // apply delta from hydration
    };
    if (state.count) nextState.count = state.count; // preserve count value on client side navigation
    return nextState;
  } else {
    return combinedReducer(state, action);
  }
};

Configuration

The createWrapper function accepts makeStore as its first argument. The makeStore function should return a new Redux Store instance each time it’s called. No memoization is needed here, it is automatically done inside the wrapper.

createWrapper also optionally accepts a config object as a second parameter:

  • debug (optional, boolean) : enable debug logging
  • serializeState and deserializeState: custom functions for serializing and deserializing the redux state, see Custom serialization and deserialization.

When makeStore is invoked it is provided with a Next.js context, which could be NextPageContext or AppContext or getStaticProps or getServerSideProps context depending on which lifecycle function you will wrap.

Some of those contexts (getServerSideProps always, and NextPageContext, AppContext sometimes if page is rendered on server) can have request and response related properties:

  • req (IncomingMessage)
  • res (ServerResponse)

Although it is possible to create server or client specific logic in both makeStore, I highly recommend that they do not have different behavior. This may cause errors and checksum mismatches which in turn will ruin the whole purpose of server rendering.

getStaticProps

This section describes how to attach to getStaticProps lifecycle function.

Let’s create a page in pages/pageName.tsx:

import React from "react";
import { NextPage } from "next";
import { useSelector } from "react-redux";
import { wrapper, State } from "../store";

export const getStaticProps = wrapper.getStaticProps(
  (store) =>
    ({ preview }) => {
      console.log("2. Page.getStaticProps uses the store to dispatch things");
      store.dispatch({
        type: "TICK",
        payload: "was set in other page " + preview,
      });
    }
);

// you can also use `connect()` instead of hooks
const Page: NextPage = () => {
  const { tick } = useSelector<State, State>((state) => state);
  return <div>{tick}</div>;
};

export default Page;
Same code in JavaScript (without types) ```js import React from "react"; import { useSelector } from "react-redux"; import { wrapper } from "../store"; export const getStaticProps = wrapper.getStaticProps( (store) => ({ preview }) => { console.log("2. Page.getStaticProps uses the store to dispatch things"); store.dispatch({ type: "TICK", payload: "was set in other page " + preview, }); } ); // you can also use `connect()` instead of hooks const Page = () => { const { tick } = useSelector((state) => state); return
{tick}
; }; export default Page; ```

:warning: Each time when pages that have getStaticProps are opened by user the HYDRATE action will be dispatched. The payload of this action will contain the state at the moment of static generation, it will not have client state, so your reducer must merge it with existing client state properly. More about this in Server and Client State Separation.

Although you can wrap individual pages (and not wrap the pages/_app) it is not recommended, see last paragraph in usage section.

getServerSideProps

This section describes how to attach to getServerSideProps lifecycle function.

Let’s create a page in pages/pageName.tsx:

import React from "react";
import { NextPage } from "next";
import { connect } from "react-redux";
import { wrapper, State } from "../store";

export const getServerSideProps = wrapper.getServerSideProps(
  (store) =>
    ({ req, res, ...etc }) => {
      console.log(
        "2. Page.getServerSideProps uses the store to dispatch things"
      );
      store.dispatch({ type: "TICK", payload: "was set in other page" });
    }
);

// Page itself is not connected to Redux Store, it has to render Provider to allow child components to connect to Redux Store
const Page: NextPage<State> = ({ tick }) => <div>{tick}</div>;

// you can also use Redux `useSelector` and other hooks instead of `connect()`
export default connect((state: State) => state)(Page);
Same code in JavaScript (without types) ```js import React from "react"; import { connect } from "react-redux"; import { wrapper } from "../store"; export const getServerSideProps = wrapper.getServerSideProps( (store) => ({ req, res, ...etc }) => { console.log( "2. Page.getServerSideProps uses the store to dispatch things" ); store.dispatch({ type: "TICK", payload: "was set in other page" }); } ); // Page itself is not connected to Redux Store, it has to render Provider to allow child components to connect to Redux Store const Page = ({ tick }) =>
{tick}
; // you can also use Redux `useSelector` and other hooks instead of `connect()` export default connect((state) => state)(Page); ```

:warning: Each time when pages that have getServerSideProps are opened by user the HYDRATE action will be dispatched. The payload of this action will contain the state at the moment of server side rendering, it will not have client state, so your reducer must merge it with existing client state properly. More about this in Server and Client State Separation.

Although you can wrap individual pages (and not wrap the pages/_app) it is not recommended, see last paragraph in usage section.

Page.getInitialProps

import React, { Component } from "react";
import { NextPage } from "next";
import { wrapper, State } from "../store";

// you can also use `connect()` instead of hooks
const Page: NextPage = () => {
  const { tick } = useSelector<State, State>((state) => state);
  return <div>{tick}</div>;
};

Page.getInitialProps = wrapper.getInitialPageProps(
  (store) =>
    ({ pathname, req, res }) => {
      console.log("2. Page.getInitialProps uses the store to dispatch things");
      store.dispatch({
        type: "TICK",
        payload: "was set in error page " + pathname,
      });
    }
);

export default Page;
Same code in JavaScript (without types) ```js import React, { Component } from "react"; import { wrapper } from "../store"; // you can also use `connect()` instead of hooks const Page = () => { const { tick } = useSelector((state) => state); return
{tick}
; }; Page.getInitialProps = wrapper.getInitialPageProps( (store) => ({ pathname, req, res }) => { console.log("2. Page.getInitialProps uses the store to dispatch things"); store.dispatch({ type: "TICK", payload: "was set in error page " + pathname, }); } ); export default Page; ```

Keep in mind that req and res may not be available if getInitialProps is called on client side.

Stateless function component also can be replaced with class:

class Page extends Component {

    public static getInitialProps = wrapper.getInitialPageProps(store => () => { ... });

    render() {
        // stuff
    }
}

export default Page;

Although you can wrap individual pages (and not wrap the pages/_app) it is not recommended, see last paragraph in usage section.

App

:warning: You can dispatch actions from the pages/_app too. But this mode is not compatible with Next.js 9’s Auto Partial Static Export feature, see the explanation below.

The wrapper can also be attached to your _app component (located in /pages). All other components can use the connect function of react-redux.

# pages/_app.tsx

import React from 'react';
import App, {AppInitialProps, AppContext} from 'next/app';
import {wrapper} from '../components/store';
import {State} from '../components/reducer';

// Since you'll be passing more stuff to Page
declare module 'next/dist/next-server/lib/utils' {
    export interface NextPageContext {
        store: Store<State>;
    }
}

class MyApp extends App<AppInitialProps> {

    public static getInitialProps = wrapper.getInitialAppProps(store => async ({Component, ctx}) => {

        store.dispatch({type: 'TOE', payload: 'was set in _app'});

        return {
            pageProps: {
                // Call page-level getInitialProps
                // DON'T FORGET TO PROVIDE STORE TO PAGE
                ...(Component.getInitialProps ? await Component.getInitialProps({...ctx, store}) : {}),
                // Some custom thing for all pages
                pathname: ctx.pathname,
            },
        };

    });

    public render() {
        const {Component, pageProps} = this.props;

        return (
            <Component {...pageProps} />
        );
    }
}

export default wrapper.withRedux(MyApp);
Same code in JavaScript (without types) ```js # pages/_app.tsx import React from 'react'; import App from 'next/app'; import {wrapper} from '../components/store'; class MyApp extends App { static getInitialProps = wrapper.getInitialAppProps(store => async ({Component, ctx}) => { store.dispatch({type: 'TOE', payload: 'was set in _app'}); return { pageProps: { // Call page-level getInitialProps // DON'T FORGET TO PROVIDE STORE TO PAGE ...(Component.getInitialProps ? await Component.getInitialProps({...ctx, store}) : {}), // Some custom thing for all pages pathname: ctx.pathname, }, }; }); render() { const {Component, pageProps} = this.props; return ( <Component {...pageProps} /> ); } } export default wrapper.withRedux(MyApp); ```

Then all pages can simply be connected (the example considers page components):

// pages/xxx.tsx

import React from "react";
import { NextPage } from "next";
import { connect } from "react-redux";
import { NextPageContext } from "next";
import { State } from "../store";

const Page: NextPage<State> = ({ foo, custom }) => (
  <div>
    <div>Prop from Redux {foo}</div>
    <div>Prop from getInitialProps {custom}</div>
  </div>
);

// No need to wrap pages if App was wrapped
Page.getInitialProps = ({ store, pathname, query }: NextPageContext) => {
  store.dispatch({ type: "FOO", payload: "foo" }); // The component can read from the store's state when rendered
  return { custom: "custom" }; // You can pass some custom props to the component from here
};

export default connect((state: State) => state)(Page);
Same code in JavaScript (without types) ```js // pages/xxx.js import React from "react"; import { connect } from "react-redux"; const Page = ({ foo, custom }) => (
Prop from Redux {foo}
Prop from getInitialProps {custom}
); // No need to wrap pages if App was wrapped Page.getInitialProps = ({ store, pathname, query }) => { store.dispatch({ type: "FOO", payload: "foo" }); // The component can read from the store's state when rendered return { custom: "custom" }; // You can pass some custom props to the component from here }; export default connect((state) => state)(Page); ```

App and getServerSideProps or getStaticProps at page level

You can also use getServerSideProps or getStaticProps at page level, in this case HYDRATE action will be dispatched twice: with state after App.getInitialProps and then with state after getServerSideProps or getStaticProps:

  • If you use getServerSideProps at page level then store in getServerSideProps will be executed after App.getInitialProps and will have state from it, so second HYDRATE will have full state from both
  • :warning: If you use getStaticProps at page level then store in getStaticProps will be executed at compile time and will NOT have state from App.getInitialProps because they are executed in different contexts and state cannot be shared. First HYDRATE actions state after App.getInitialProps and second will have state after getStaticProps (even though it was executed earlier in time).

Simplest way to ensure proper merging is to drop initial values from action.payload:

const reducer = (
  state: State = { app: "init", page: "init" },
  action: AnyAction
) => {
  switch (action.type) {
    case HYDRATE:
      if (action.payload.app === "init") delete action.payload.app;
      if (action.payload.page === "init") delete action.payload.page;
      return { ...state, ...action.payload };
    case "APP":
      return { ...state, app: action.payload };
    case "PAGE":
      return { ...state, page: action.payload };
    default:
      return state;
  }
};
Same code in JavaScript (without types) ```js const reducer = (state = { app: "init", page: "init" }, action) => { switch (action.type) { case HYDRATE: if (action.payload.app === "init") delete action.payload.app; if (action.payload.page === "init") delete action.payload.page; return { ...state, ...action.payload }; case "APP": return { ...state, app: action.payload }; case "PAGE": return { ...state, page: action.payload }; default: return state; } }; ```

Assume page only dispatches PAGE action and App only APP, this makes state merging safe.

More about that in Server and Client state separation.

How it works

Using next-redux-wrapper (“the wrapper”), the following things happen on a request:

  • Phase 1: getInitialProps/getStaticProps/getServerSideProps

    • The wrapper creates a server-side store (using makeStore) with an empty initial state. In doing so it also provides the Request and Response objects as options to makeStore.
    • In App mode:
      • The wrapper calls the _app’s getInitialProps function and passes the previously created store.
      • Next.js takes the props returned from the _app’s getInitialProps method, along with the store’s state.
    • In per-page mode:
      • The wrapper calls the Page’s getXXXProps function and passes the previously created store.
      • Next.js takes the props returned from the Page’s getXXXProps method, along with the store’s state.
  • Phase 2: SSR

    • The wrapper creates a new store using makeStore
    • The wrapper dispatches HYDRATE action with the previous store’s state as payload
    • That store is passed as a property to the _app or page component.
    • Connected components may alter the store’s state, but the modified state will not be transferred to the client.
  • Phase 3: Client

    • The wrapper creates a new store
    • The wrapper dispatches HYDRATE action with the state from Phase 1 as payload
    • That store is passed as a property to the _app or page component.
    • The wrapper persists the store in the client’s window object, so it can be restored in case of HMR.

Note: The client’s state is not persisted across requests (i.e. Phase 1 always starts with an empty state). Hence, it is reset on page reloads. Consider using Redux persist if you want to persist state between requests.

Tips and Tricks

Redux Toolkit

Since version 7.0 first-class support of @reduxjs/toolkit has been added.

Full example: https://github.com/kirill-konshin/next-redux-wrapper/blob/master/packages/demo-redux-toolkit.

import { configureStore, createSlice, ThunkAction } from "@reduxjs/toolkit";
import { Action } from "redux";
import { createWrapper, HYDRATE } from "next-redux-wrapper";

export const subjectSlice = createSlice({
  name: "subject",

  initialState: {} as any,

  reducers: {
    setEnt(state, action) {
      return action.payload;
    },
  },

  extraReducers: {
    [HYDRATE]: (state, action) => {
      console.log("HYDRATE", state, action.payload);
      return {
        ...state,
        ...action.payload.subject,
      };
    },
  },
});

const makeStore = () =>
  configureStore({
    reducer: {
      [subjectSlice.name]: subjectSlice.reducer,
    },
    devTools: true,
  });

export type AppStore = ReturnType<typeof makeStore>;
export type AppState = ReturnType<AppStore["getState"]>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  AppState,
  unknown,
  Action
>;

export const fetchSubject =
  (id: any): AppThunk =>
  async (dispatch) => {
    const timeoutPromise = (timeout: number) =>
      new Promise((resolve) => setTimeout(resolve, timeout));

    await timeoutPromise(200);

    dispatch(
      subjectSlice.actions.setEnt({
        [id]: {
          id,
          name: `Subject ${id}`,
        },
      })
    );
  };

export const wrapper = createWrapper<AppStore>(makeStore);

export const selectSubject = (id: any) => (state: AppState) =>
  state?.[subjectSlice.name]?.[id];

It is recommended to export typed State and ThunkAction:

export type AppStore = ReturnType<typeof makeStore>;
export type AppState = ReturnType<AppStore["getState"]>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  AppState,
  unknown,
  Action
>;

Server and Client state separation

Each time when pages that have getStaticProps or getServerSideProps are opened by user the HYDRATE action will be dispatched. The payload of this action will contain the state at the moment of static generation or server side rendering, so your reducer must merge it with existing client state properly.

The easiest and most stable way to make sure nothing is accidentally overwritten is to make sure that your reducer applies client side and server side actions to different substates of your state and they never clash:

export interface State {
  server: any;
  client: any;
}

const reducer = (state: State = { tick: "init" }, action: AnyAction) => {
  switch (action.type) {
    case HYDRATE:
      return {
        ...state,
        server: {
          ...state.server,
          ...action.payload.server,
        },
      };
    case "SERVER_ACTION":
      return {
        ...state,
        server: {
          ...state.server,
          tick: action.payload,
        },
      };
    case "CLIENT_ACTION":
      return {
        ...state,
        client: {
          ...state.client,
          tick: action.payload,
        },
      };
    default:
      return state;
  }
};
Same code in JavaScript (without types) ```js const reducer = (state = { tick: "init" }, action) => { switch (action.type) { case HYDRATE: return { ...state, server: { ...state.server, ...action.payload.server, }, }; case "SERVER_ACTION": return { ...state, server: { ...state.server, tick: action.payload, }, }; case "CLIENT_ACTION": return { ...state, client: { ...state.client, tick: action.payload, }, }; default: return state; } }; ```

If you prefer an isomorphic approach for some (preferably small) portions of your state, you can share them between client and server on server-rendered pages using next-redux-cookie-wrapper, an extension to next-redux-wrapper. In this case, for selected substates, the server is aware of the client’s state (unless in getStaticProps) and there is no need to separate server and client state.

Also you can use a library like https://github.com/benjamine/jsondiffpatch to analyze diff and apply it properly.

Document

I don’t recommend using withRedux in pages/_document.js, Next.JS does not provide a reliable way to determine the sequence when components will be rendered. So per Next.JS recommendation it is better to have just data-agnostic things in pages/_document.

Error Pages

Error pages can also be wrapped the same way as any other pages.

Transition to an error page (pages/_error.js template) will cause pages/_app.js to be applied but it is always a full page transition (not HTML5 pushState), so client will have the store created from scratch using state from the server. So unless you persist the store on the client somehow the resulting previous client state will be ignored.

Async actions

You can use https://github.com/reduxjs/redux-thunk to dispatch async actions:

function someAsyncAction(id) {
  return async function (dispatch, getState) {
    return someApiCall(id).then((res) => {
      dispatch({
        type: "FOO",
        payload: res,
      });
    });
  };
}

// usage
await store.dispatch(someAsyncAction());

You can also install https://github.com/pburtchaell/redux-promise-middleware in order to dispatch Promises as async actions. Follow the installation guide of the library, then you’ll be able to handle it like this:

function someAsyncAction() {
  return {
    type: "FOO",
    payload: new Promise((resolve) => resolve("foo")),
  };
}

// usage
await store.dispatch(someAsyncAction());

Custom serialization and deserialization

If you are storing complex types such as Immutable.JS or JSON objects in your state, a custom serialize and deserialize handler might be handy to serialize the redux state on the server and deserialize it again on the client. To do so, provide serializeState and deserializeState as config options to withRedux.

The reason is that state snapshot is transferred over the network from server to client as a plain object.

Example of a custom serialization of an Immutable.JS state using json-immutable:

const { serialize, deserialize } = require("json-immutable");

createWrapper({
  serializeState: (state) => serialize(state),
  deserializeState: (state) => deserialize(state),
});

Same thing using Immutable.JS:

const { fromJS } = require("immutable");

createWrapper({
  serializeState: (state) => state.toJS(),
  deserializeState: (state) => fromJS(state),
});

Usage with Redux Saga

[Note, this method may be unsafe - make sure you put a lot of thought into handling async sagas correctly. Race conditions happen very easily if you aren’t careful.] To utilize Redux Saga, one simply has to make some changes to their makeStore function. Specifically, redux-saga needs to be initialized inside this function, rather than outside of it. (I did this at first, and got a nasty error telling me Before running a Saga, you must mount the Saga middleware on the Store using applyMiddleware). Here is how one accomplishes just that. This is just slightly modified from the setup example at the beginning of the docs. Keep in mind that this setup will opt you out of Automatic Static Optimization: https://err.sh/next.js/opt-out-auto-static-optimization.

Create your root saga as usual, then implement the store creator:

import { createStore, applyMiddleware, Store } from "redux";
import { createWrapper, Context } from "next-redux-wrapper";
import createSagaMiddleware, { Task } from "redux-saga";
import reducer, { State } from "./reducer";
import rootSaga from "./saga";

export interface SagaStore extends Store {
  sagaTask?: Task;
}

export const makeStore = (context: Context) => {
  // 1: Create the middleware
  const sagaMiddleware = createSagaMiddleware();

  // 2: Add an extra parameter for applying middleware:
  const store = createStore(reducer, applyMiddleware(sagaMiddleware));

  // 3: Run your sagas on server
  (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga);

  // 4: now return the store:
  return store;
};

export const wrapper = createWrapper<Store<State>>(makeStore, { debug: true });
Same code in JavaScript (without types) ```js import {createStore, applyMiddleware} from 'redux'; import {createWrapper} from 'next-redux-wrapper'; import createSagaMiddleware from 'redux-saga'; import reducer from './reducer'; import rootSaga from './saga'; export const makeStore = (context) => { // 1: Create the middleware const sagaMiddleware = createSagaMiddleware(); // 2: Add an extra parameter for applying middleware: const store = createStore(reducer, applyMiddleware(sagaMiddleware)); // 3: Run your sagas on server (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga); // 4: now return the store: return store; }; export const wrapper = createWrapper(makeStore, {debug: true}); ```

Using pages/_app

Then in the pages/_app wait stop saga and wait for it to finish when execution is on server:

import React from "react";
import App, { AppInitialProps, AppContext } from "next/app";
import { END } from "redux-saga";
import { SagaStore, wrapper } from "../components/store";

class WrappedApp extends App<AppInitialProps> {
  public static getInitialProps = async ({ Component, ctx }: AppContext) => {
    // 1. Wait for all page actions to dispatch
    const pageProps = {
      ...(Component.getInitialProps
        ? await Component.getInitialProps(ctx)
        : {}),
    };

    // 2. Stop the saga if on server
    if (ctx.req) {
      ctx.store.dispatch(END);
      await (ctx.store as SagaStore).sagaTask.toPromise();
    }

    // 3. Return props
    return {
      pageProps,
    };
  };

  public render() {
    const { Component, pageProps } = this.props;
    return <Component {...pageProps} />;
  }
}

export default wrapper.withRedux(WrappedApp);
Same code in JavaScript (without types) ```js import React from 'react'; import App from 'next/app'; import {END} from 'redux-saga'; import {SagaStore, wrapper} from '../components/store'; class WrappedApp extends App { public static getInitialProps = async ({Component, ctx}) => { // 1. Wait for all page actions to dispatch const pageProps = { ...(Component.getInitialProps ? await Component.getInitialProps(ctx) : {}), }; // 2. Stop the saga if on server if (ctx.req) { ctx.store.dispatch(END); await (ctx.store as SagaStore).sagaTask.toPromise(); } // 3. Return props return { pageProps, }; }; public render() { const {Component, pageProps} = this.props; return <Component {...pageProps} />; } } export default wrapper.withRedux(WrappedApp); ```

Using getServerSideProps or getStaticProps

In order to use it with getServerSideProps or getStaticProps you need to await for sagas in each page’s handler:

export const getServerSideProps = ReduxWrapper.getServerSideProps(
  async ({ store, req, res, ...etc }) => {
    // regular stuff
    store.dispatch(ApplicationSlice.actions.updateConfiguration());
    // end the saga
    store.dispatch(END);
    await store.sagaTask.toPromise();
  }
);

Usage without getInitialProps inside _app

If you don’t want to opt-out of automatic pre-rendering in your Next.js app, you can manage server-called sagas on a per page basis like the official Next.js “with Redux Saga” example does. If you do go with this option, please ensure that you await any and all sagas within any Next.js page methods. If you miss it on one of pages you’ll end up with inconsistent state being sent to client. So, we consider waiting in _app to be automatically safer, but obviously the main drawback is opting out of automatic static exports.

Usage with Redux Persist

If you only need to persist small portions of your state, next-redux-cookie-wrapper might be an easy alternative to Redux Persist that supports SSR.

Boilerplate: https://github.com/fazlulkarimweb/with-next-redux-wrapper-redux-persist

Honestly, I think that putting a persistence gate is not necessary because the server can already send some HTML with some state, so it’s better to show it right away and then wait for REHYDRATE action to happen to show additional delta coming from persistence storage. That’s why we use Server Side Rendering in the first place.

But, for those who actually want to block the UI while rehydration is happening, here is the solution (still hacky though):

// lib/redux.js
import logger from "redux-logger";
import { applyMiddleware, createStore } from "redux";

const SET_CLIENT_STATE = "SET_CLIENT_STATE";

export const reducer = (state, { type, payload }) => {
  // Usual stuff with HYDRATE handler
  if (type === SET_CLIENT_STATE) {
    return {
      ...state,
      fromClient: payload,
    };
  }
  return state;
};

const makeConfiguredStore = (reducer) =>
  createStore(reducer, undefined, applyMiddleware(logger));

const makeStore = () => {
  const isServer = typeof window === "undefined";

  if (isServer) {
    return makeConfiguredStore(reducer);
  } else {
    // we need it only on client side
    const { persistStore, persistReducer } = require("redux-persist");
    const storage = require("redux-persist/lib/storage").default;

    const persistConfig = {
      key: "nextjs",
      whitelist: ["fromClient"], // make sure it does not clash with server keys
      storage,
    };

    const persistedReducer = persistReducer(persistConfig, reducer);
    const store = makeConfiguredStore(persistedReducer);

    store.__persistor = persistStore(store); // Nasty hack

    return store;
  }
};

export const wrapper = createWrapper(makeStore);

export const setClientState = (clientState) => ({
  type: SET_CLIENT_STATE,
  payload: clientState,
});

And then in Next.js _app page you can use bare context access to get the store (https://react-redux.js.org/api/provider#props):

// pages/_app.tsx
import React from "react";
import App from "next/app";
import { ReactReduxContext } from "react-redux";
import { wrapper } from "./lib/redux";
import { PersistGate } from "redux-persist/integration/react";

export default wrapper.withRedux(
  class MyApp extends App {
    render() {
      const { Component, pageProps } = this.props;
      return (
        <ReactReduxContext.Consumer>
          {({ store }) => {
            <PersistGate
              persistor={store.__persistor}
              loading={<div>Loading</div>}
            >
              <Component {...pageProps} />
            </PersistGate>;
          }}
        </ReactReduxContext.Consumer>
      );
    }
  }
);

Or using hooks:

// pages/_app.tsx
import React from "react";
import App from "next/app";
import { useStore } from "react-redux";
import { wrapper } from "./lib/redux";
import { PersistGate } from "redux-persist/integration/react";

export default wrapper.withRedux(({ Component, pageProps }) => {
  const store = useStore();
  return (
    <PersistGate persistor={store.__persistor} loading={<div>Loading</div>}>
      <Component {...pageProps} />
    </PersistGate>
  );
});

And then in Next.js page:

// pages/index.js
import React from "react";
import { connect } from "react-redux";

export default connect((state) => state, { setClientState })(
  ({ fromServer, fromClient, setClientState }) => (
    <div>
      <div>fromServer: {fromServer}</div>
      <div>fromClient: {fromClient}</div>
      <div>
        <button onClick={(e) => setClientState("bar")}>Set Client State</button>
      </div>
    </div>
  )
);

Upgrade from 6.x to 7.x

  1. Signature of createWrapper has changed: instead of createWrapper<State> you should use createWrapper<Store<State>>, all types will be automatically inferred from Store.

  2. GetServerSidePropsContext and GetStaticPropsContext are no longer exported from next-redux-wrapper, you should use GetServerSideProps, GetServerSidePropsContext, GetStaticProps and GetStaticPropsContext directly from next.

  3. All signatures like ({store, req, res, ...}) => { ... } were changed to store => ({req, res, ...}) => { ... } in order to keep Next.js internals free of modifications and for better typings support.

  4. In version 7.x you have to manually wrap all getInitialProps with proper wrappers: wrapper.getInitialPageProps and wrapper.getInitialAppProps.

  5. window.NEXT_REDUX_WRAPPER_STORE has been removed as it was causing issues with hot reloading

Upgrade from 5.x to 6.x

Major change in the way how things are wrapped in version 6.

  1. Default export withRedux is marked deprecated, you should create a wrapper const wrapper = createWrapper(makeStore, {debug: true}) and then use wrapper.withRedux(MyApp).

  2. Your makeStore function no longer gets initialState, it only receives the context: makeStore(context: Context). Context could be NextPageContext or AppContext or getStaticProps or getServerSideProps context depending on which lifecycle function you will wrap. Instead, you need to handle the HYDRATE action in the reducer. The payload of this action will contain the state at the moment of static generation or server side rendering, so your reducer must merge it with existing client state properly.

  3. App should no longer wrap its children with Provider, it is now done internally.

  4. isServer is not passed in context/props, use your own function or simple check const isServer = typeof window === 'undefined' or !!context.req or !!context.ctx.req.

  5. store is not passed to wrapped component props.

  6. WrappedAppProps was renamed to WrapperProps.

Upgrade from 1.x to 2.x

If your project was using Next.js 5 and Next Redux Wrapper 1.x these instructions will help you to upgrade to 2.x.

  1. Upgrade Next.js and Wrapper

    $ npm install next@6 --save-dev
    $ npm install next-redux-wrapper@latest --save
    
  2. Replace all usages of import withRedux from "next-redux-wrapper"; and withRedux(...)(WrappedComponent) in all your pages with plain React Redux connect HOC:

    import {connect} from "react-redux";
    
    export default connect(...)(WrappedComponent);
    

    You also may have to reformat your wrapper object-based config to simple React Redux config.

  3. Create the pages/_app.js file with the following minimal code:

    // pages/_app.js
    import React from "react";
    import { Provider } from "react-redux";
    import App from "next/app";
    import { wrapper } from "../store";
    
    class MyApp extends App {
      static async getInitialProps({ Component, ctx }) {
        return {
          pageProps: {
            // Call page-level getInitialProps
            ...(Component.getInitialProps
              ? await Component.getInitialProps(ctx)
              : {}),
          },
        };
      }
    
      render() {
        const { Component, pageProps } = this.props;
        return <Component {...pageProps} />;
      }
    }
    
    export default wrapper.withRedux(MyApp);
    
  4. Follow Next.js 6 upgrade instructions for all your components (props.router instead of props.url and so on)

That’s it. Your project should now work the same as before.

요약

  • 1.
  • 2.
  • 3.

참고 영상

참조 문서 및 사이트

상단으로 푸샤 깃허브 이동

댓글남기기