이번에는 Next.js에서 전역으로 상태 관리를 하는법이 React와 다른거 같길래 너무너무 궁금해서 한번 조사하고 예시코드를 작성해보도록 하겠다. 참고는 https://github.com/vercel/next.js/tree/canary/examples/with-redux-thunk
또한 공식 문서 https://github.com/kirill-konshin/next-redux-wrapper next-redux-wrapper를 참고하였습니다.
Redux
다들 알다 싶이 redux는 유명한 전역 상태 관리 라이브러리 이다. 하지만 이를 next.js에서 어떻게 사용하면 좋을까에 대해서 조사해보고 직접 관련 예제를 작성해 보았다.
Redux Wrapper for Next.js
정적 앱을 위한 Redux 설정은 다소 간단합니다. 모든 페이지에 제공되는 단일 Redux 저장소를 생성해야 합니다. 하지만 Next.js의 SSG나 SSR이 포함되는 순간 상황은 매우 복잡해지게 됩니다. 그 이유는 서버에서 redux와 연결되어 있는 컴포넌트가 랜더링할 때 하나 더 필요하기 때문입니다.
게다가, 페이지의 pre-rendering을 위한 getIntialProps에서 redux의 store에 접근할 수 있어야 합니다.
이러한 필요성에 의해서 next-redux-wrapper를 만든 것입니다. 이는 자동으로 우리를 위해 저장소 객체를 만들어 주고 서버와 클라이언트에서 모두 동일한 상태를 유지할 수 있게 도와주는 역할을 수행합니다.
또한 이는 pages/_app에서 App.getInitialProps를 사용하고 각각의 페이지 레벨에서 getStaticProps와 getServerSideProps를 사용할 떄 매우 유용합니다.
그냥 코드를 보며 이해해 봅시다.
Usage
Next.js는 많은 data-fetching메커니즘을 가지고 있습니다. 이 next-redux-wrapper는 이 메커니즘에 모두 적용됩니다. 다만 우리는 공통된 코드를 작성해야 합니다 ( store.js )
또한 우리는 반드시 HYDRATE action 핸들러가 필요합니다. HYDRATE action 핸들러는 서버에서 pre-rendering된 데이터를 클라이언트 단에서 hydrate할 때 사용되므로 매우 중요한 action입니다.
일단 제가 작성한 코드를 소개하겠습니다.
// modules/counter.tsx
import { createAction, handleActions } from "redux-actions";
import * as types from "../types";
export interface ICounterState {
counter: number;
}
const initialCounterState: ICounterState = {
counter: 0,
};
const counterReducer = handleActions(
{
[types.INCREMENT]: ({ counter }: ICounterState, { payload }) => ({
counter: counter + 1,
}),
[types.DECREMENT]: ({ counter }: ICounterState, { payload }) => ({
counter: counter - 1,
}),
[types.RESET]: () => ({
counter: 0,
}),
},
initialCounterState
);
export const incrementCount = createAction(types.INCREMENT);
export const decrementCount = createAction(types.DECREMENT);
export const resetCount = createAction(types.RESET);
export default counterReducer;
// modules/timer.tsx
// @ts-nocheck
import { createAction, handleActions } from "redux-actions";
import * as types from "../types";
export interface ITimerState {
lastUpdate: number;
light: boolean;
}
const initialTimerState: ITimerState = {
lastUpdate: 0,
light: false,
};
export const TICK = createAction(types.TICK, (light: boolean) => ({
light,
ts: Date.now(),
}));
export const TICK2 = createAction(
types.TICK2,
(content) => "was set in other page" + content
);
const timerReducer = handleActions(
{
[types.TICK]: (state, { payload }) => ({
lastUpdate: payload.ts,
light: !!payload.light,
}),
[types.TICK2]: (state, { payload }) => ({
...state,
payload,
}),
},
initialTimerState
);
export const serverRenderClock = () => (dispatch) => {
dispatch(TICK(false));
};
export const startClock = () => (dispatch) => {
console.log("start clock!!");
setInterval(() => {
dispatch(TICK(true));
}, 1000);
};
export default timerReducer;
// modules/user.tsx
// @ts-nocheck
import { handleActions } from "redux-actions";
import * as types from "../types";
import axios from "axios";
export interface IUserState {
users: Object[];
error: string;
}
const initialUserState: IUserState = {
users: [],
error: null,
};
const allUsersReducer = (state = { users: [] }, { type, payload }) => {
switch (type) {
case types.ALL_USERS_SUCCESS:
return {
...state,
users: payload,
};
case types.ALL_USERS_FAIL:
return {
...state,
error: payload,
};
case types.CLEAR_ERRORS:
return {
...state,
error: null,
};
default:
return state;
}
};
export const getUsers = () => async (dispatch) => {
try {
const { data } = await axios.get("https://jsonplaceholder.typicode.com/users");
dispatch({
type: types.ALL_USERS_SUCCESS,
payload: data,
});
} catch (error) {
dispatch({
type: types.ALL_USERS_FAIL,
payload: error.response.data,
});
}
};
export const clearErrors = () => async (dispatch) => {
dispatch({
type: types.CLEAR_ERRORS,
});
};
export default allUsersReducer;
// modules/index.tsx
// @ts-nocheck
import counterReducer from "./counter";
import timerReducer from "./timer";
import allUsersReducer from "./user";
import { combineReducers, applyMiddleware, createStore } from "redux";
import { handleActions } from "redux-actions";
import { createLogger } from "redux-logger";
import thunkMiddleware from "redux-thunk";
import { createWrapper, HYDRATE } from "next-redux-wrapper";
import type { Context } from "next-redux-wrapper";
import type { ICounterState } from "./counter";
import type { ITimerState } from "./timer";
const rootReducer = combineReducers({
counterReducer,
timerReducer,
allUsersReducer,
});
const reducer = (state, action) => {
if (action.type === HYDRATE) {
const nextState = {
...state,
...action.payload,
};
return nextState;
} else {
return rootReducer(state, action);
}
};
const bindMiddleware = (middleware: any) => {
if (process.env.NODE_ENV !== "production") {
const { composeWithDevTools } = require("redux-devtools-extension");
return composeWithDevTools(applyMiddleware(...middleware));
}
return applyMiddleware(...middleware);
};
// create a makeStore function
const initStore = (context: Context) => {
const logger = createLogger();
const middleware = [logger, thunkMiddleware];
return createStore(reducer, bindMiddleware([...middleware]));
};
// export an assembled wrapper
// @ts-ignore
export const wrapper = createWrapper(initStore, { debug: true });
저는 counter, timer, user store를 만들었으며 index store에서 combine했습니다.
...
const reducer = (state, action) => {
if (action.type === HYDRATE) {
const nextState = {
...state,
...action.payload,
};
return nextState;
} else {
return rootReducer(state, action);
}
};
const initStore = (context: Context) => {
const logger = createLogger();
const middleware = [logger, thunkMiddleware];
return createStore(reducer, bindMiddleware([...middleware]));
};
// export an assembled wrapper
// @ts-ignore
export const wrapper = createWrapper(initStore, { debug: true });
이 부분이 매우 중요한데 reducer는 rootReducer에 HYDRATE action을 추가 한것입니다. 또한 initStore는 redux store를 반환하는 함수 입니다. 이 initStore을 통해 새로운 wrapper HOC를 만드는 것이 모든 next-redux-wrapper에서 수행해 주어야하는 필수 과제입니다.
그 다음에는 바로 users 컴포넌트를 만들고 SSR을 수행해 보도록 하겠습니다.
// pages/users/index.tsx
// @ts-nocheck
import React from "react";
import { getUsers } from "../../modules/user";
import { wrapper } from "../../modules";
import { connect } from "react-redux";
const Users = ({ users }) => {
return (
<div>
<ul>
{users.map((user) => {
return <li key={user.id}>{user.name}</li>;
})}
</ul>
</div>
);
};
export const getServerSideProps = wrapper.getServerSideProps(
(store) =>
async ({ req, res, ...etc }) => {
await store.dispatch(getUsers());
}
);
export default connect((state) => state.allUsersReducer)(Users);
이렇게 간단하게 redux-thunk middleware를 사용하여 비동기적으로 users데이터를 받아온 다음에 초기 화면에 pre-rendering하고 dom을 CSR방식으로 생성해서 화면에 보이게 하는 과정을 진행해 보도록 하겠습니다. 또한 redux-logger를 사용해서 middleware의 흐름을 보다 보기 쉽게 표현하였습니다.
또한 redux-logger를 통해 next server console에 찍힌 정보를 해석해 우리가 작성한 코드가 어떻게 돌아간 것인지 해석해 보도록 하겠습니다.
1. getProps created store with state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: { users: [] }
}
이는 store를 초기화 하는 작업입니다.
action undefined @ 20:44:21.716
prev state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: { users: [] }
}
action [AsyncFunction (anonymous)]
next state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: { users: [] }
}
이는 redux-thunk를 통한 비동기적으로 server-side에서 getUsers()메서드를 dispatch하는 작업입니다. getUsers()의 반환값은 (dispatch) => {} 이므로 action은 당연히 undefined이고 prev state에는 초기화 된 reducer상태가 온전히 있는 것을 볼 수 있으며 (dispatch) => {}타입의 action이 실행되고 일단 next state또한 그대로 인 것을 확인할 수 있습니다.
action user/ALL_USERS_SUCCESS @ 20:44:21.878
prev state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: { users: [] }
}
action {
type: 'user/ALL_USERS_SUCCESS',
payload: [
{
id: 1,
name: 'Leanne Graham',
username: 'Bret',
email: 'Sincere@april.biz',
address: [Object],
phone: '1-770-736-8031 x56442',
website: 'hildegard.org',
company: [Object]
},
.....
]
}
next state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: {
users: [
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object]
]
}
}
getUsers()함수 안에서 type이 'user/ALL_USERS_SUCCESS"을 호출하면서 payload에 axios로 받아온 데이터를 넘겨준 action을 reducer에서 처리하게 두었습니다. 그랬더니 next state가 자연스럽게 prev state에서 allUsersReducer부분만 변경된 것을 볼 수 있는데 allUsersReducer.users부분에 우리가 렌더링 하고싶던 user object들이 있는 것을 볼 수 있습니다.
3. getProps after dispatches has store state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: {
users: [
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object]
]
}
}
그다음에는 다시한번 redux의 상태를 표시 한 것입니다.
4. WrappedApp created new store with withRedux(MyApp) {
initialState: undefined,
initialStateFromGSPorGSSR: {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: { users: [Array] }
}
}
그 다음은 정확히 뭔진 모르겠으나 initialStateFromGSPorGSSR을 보면 allUsersReducer에 우리가 사용하고 싶은 users배열이 있는 것을 볼 수 있습니다.
action __NEXT_REDUX_WRAPPER_HYDRATE__ @ 20:44:21.892
prev state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: { users: [] }
}
action {
type: '__NEXT_REDUX_WRAPPER_HYDRATE__',
payload: {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: { users: [Array] }
}
}
next state {
counterReducer: { counter: 0 },
timerReducer: { lastUpdate: 0, light: false },
allUsersReducer: {
users: [
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object],
[Object], [Object]
]
}
그 다음은 HYDRATE action인데 서버 사이드에서 렌더링된 state가 HYDRATE action의 payload에 저장되어 있을 것입니다. 이 payload를 이제 클라이언트 사이드의 redux state에 삽입하는 작업을 진행하는데 흔이 우리는 이를 hydration이라고 합니다.
공식문서 How it works
- 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 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 sotre's state
- Phase 2: SSR
- The wrapper creats 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
'Web > NextJs' 카테고리의 다른 글
Next.js - Advanced Custom app + next-redux-wrapper를 활용한 data pre-rendering (0) | 2022.03.03 |
---|---|
Next.js - Dynamic Routes ( TODO ) (0) | 2022.03.03 |
Next.js - Static File Serving (0) | 2022.02.25 |
Next.js - Font Optimization (0) | 2022.02.25 |
Next.js - Image Optimization (0) | 2022.02.25 |