기존 JS + Jquery로 진행한 프로젝트를 리뉴얼하면서 새로운 언어스킬을 늘리려고 한다.
그래서 Next.js + ReduxToolkit + TypeScript의 환경을 구성하게 되었는데 생각해보면
Next.js : SSR, Redux : CSR 형태이기에 설정이 필요했다.
물론 Next.js는 CSR사용이 가능하다.
먼저 Next.js는 서버 프레임워크이다. 사전에 서버측에서 렌더링을 진행해 바로 사용자에게 보여준다.
하지만 Redux는 클라이언트 전역상태 관리 라이브러리다. 즉 클라이언트쪽에서 전역상태를 활용한다.
그렇다면 다른 브라우저의 전역상태에 영향없이 하나의 사용자 전역상태 상태만 가져와 수정해 다시 해당 사용자에게 동기화 해줘야한다.
이를 가능하도록 한 라이브러리가 next-redux-wrapper이다.
그렇다면 처음부터 따라가면 해보자
먼저 cna를 이용해 초기설정을 하자 (TS, ESLint, Taillwind, Approuter)
npx create-next-app@latest
당장은 next-redux-wrapper을 이용한 서버측과의 통신이 필요하지 않다.
yarn add redux react-redux @types/react-redux @reduxjs/toolkit next-redux-wrapper
먼저 store를 구성해야한다.
아래 예제를 보자 먼저 configureStore를 통해 store를 구성한다. 특히 devTools를 통해 후에 devTools를 통해 확인 가능 하도록 설정한다.
그 밑에는 RootState타입과 Dispatch타입을 선언한다.
/* /redux/store.ts */
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {},
devTools: process.env.NODE_ENV !== "production",
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
아래 예제는 Redux의 hooks를 모아두는 파일이다. 이렇게 hooks를 모아서 사용함으로써 이후에
바로 가져와 쓰도록한다. (잠재적 종속성 문제를 사전에 해결)
/* /redux/hooks.ts */
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
아래 예제는 next13버젼 이후 정형화된 AppRouter방식에서 Provider로 감싸는 방법이다.
이렇게 만든 Provider를 이후에 layout에 적용한다.
/* /redux/provider.tsx */
"use client";
import { store } from "./store";
import { Provider } from "react-redux";
export function Providers({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
아래는 최상위 layout파일에 provider을 적용해 redux를 사용가능 하도록 만든 것이다.
/* /layout.tsx */
import { Providers } from "@/redux/provider";
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
아래는 대망의 슬라이스이다. 보통featuers폴더를 만들어 관리한다. 아래 슬라이스는 숫자를 늘려주고 줄여주는 액션을 취한다.
/* /redux/featuers/counterSlice.ts */
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type CounterState = {
value: number;
};
const initialState = {
value: 0,
} as CounterState;
export const counter = createSlice({
name: "counter",
initialState,
reducers: {
reset: () => initialState,
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
decrementByAmount: (state, action: PayloadAction<number>) => {
state.value -= action.payload;
},
},
});
export const {
increment,
incrementByAmount,
decrement,
decrementByAmount,
reset,
} = counter.actions;
export default counter.reducer;
그럼 위로 올라가서 store에 새로운 슬라이스를 등록한다.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./features/counterSlice";
export const store = configureStore({
reducer: {
counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
마지막으로 인터페이스를 구성하면 끝이다.
/* /page.tsx */
"use client";
import { decrement, increment, reset } from "./redux/features/counterSlice";
import { useAppDispatch, useAppSelector } from "./redux/hooks";
export default function Home() {
const count = useAppSelector((state) => state.counterReducer.value);
const dispatch = useAppDispatch();
return (
<main style={{ maxWidth: 1200, marginInline: "auto", padding: 20 }}>
<div style={{ marginBottom: "4rem", textAlign: "center" }}>
<h4 style={{ marginBottom: 16 }}>{count}</h4>
<button onClick={() => dispatch(increment())}>increment</button>
<button
onClick={() => dispatch(decrement())}
style={{ marginInline: 16 }}
>
decrement
</button>
<button onClick={() => dispatch(reset())}>reset</button>
</div>
</main>
);
}
RTK Query사용하기
위에 과정을 이용하면 기본적인 Redux를 이용한 전역 상태관리를 이용할 수 있습니다. 하지만 서버와 통신하고 적적한 데이터를 API를 이용해 받아오려면 RTK Query를 사용해야합니다.
그렇다면 https://jsonplaceholder.typicode.com/ 사이트를 이용해 유저데이터를 받고 이를 활용해서 진행해보겠습니다.
createApi() : RTK Query 핵심 기능입니다. 데이터를 패치하고 변환하는 설정을 포함해서 엔드포인트들에서 어떻게 데이터를 패치하는지 정의할 수 있습니다. 대부분의 케이스에서는 베이스 URL당 하나의 API 슬라이스를 사용해야 합니다.
fetchBaseQuery() : 간단한 요청을 위한 fetch의 래퍼입니다. 대부분의 사용자에게 createApi의 baseQuery로 권장합니다.
아래 예제를 보겠습니다. 먼전 받아올 User타입을 선언 해 줍니다. 그리고 createApi를 통해서 기본 설정을 합니다.
/* /redux/services/userApi.ts */
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
type User = {
id: number;
name: string;
email: number;
};
export const userApi = createApi({
reducerPath: "userApi",
refetchOnFocus: true,
baseQuery: fetchBaseQuery({
baseUrl: "https://jsonplaceholder.typicode.com/",
}),
endpoints: (builder) => ({
getUsers: builder.query<User[], null>({
query: () => "users",
}),
getUserById: builder.query<User, { id: string }>({
query: ({ id }) => `users/${id}`,
}),
}),
});
export const { useGetUsersQuery, useGetUserByIdQuery } = userApi;
이후 store에 저장합니다. 그리고 미들웨어를 만들어서 통신하도록 합니다.
/* /redux/store.ts */
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./features/counterSlice";
import { userApi } from "./services/userApi";
import { setupListeners } from "@reduxjs/toolkit/dist/query";
export const store = configureStore({
reducer: {
counterReducer,
[userApi.reducerPath]: userApi.reducer,
},
devTools: process.env.NODE_ENV !== "production",
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({}).concat([userApi.middleware]),
});
setupListeners(store.dispatch);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
page 설정은 아래처럼 추가합니다.
"use client";
import { useGetUsersQuery } from "./redux/services/userApi";
import { useState } from "react";
import {
decrement,
increment,
reset,
decrementByAmount,
incrementByAmount,
} from "./redux/features/counterSlice";
import { useAppDispatch, useAppSelector } from "./redux/hooks";
export default function Home() {
const count = useAppSelector((state) => state.counterReducer.value);
const [amount, setAmount] = useState(0);
const dispatch = useAppDispatch();
const { isLoading, isFetching, data, error } = useGetUsersQuery(null);
return (
<main style={{ maxWidth: 1200, marginInline: "auto", padding: 20 }}>
<div style={{ marginBottom: "4rem", textAlign: "center" }}>
<h4 style={{ marginBottom: 16 }}>{count}</h4>
<input
onChange={(e) => setAmount(parseInt(e.target.value))}
value={amount}
style={{ marginBottom: 16 }}
/>
<button onClick={() => dispatch(increment())}>increment</button>
<button
onClick={() => dispatch(decrement())}
style={{ marginInline: 16 }}
>
decrement
</button>
<button onClick={() => dispatch(incrementByAmount(amount))}>
incrementAmount
</button>
<button
onClick={() => dispatch(decrementByAmount(amount))}
style={{ marginInline: 16 }}
>
decrementAmount
</button>
<button onClick={() => dispatch(reset())}>reset</button>
</div>
{error ? (
<p>Oh no, there was an error</p>
) : isLoading || isFetching ? (
<p>Loading...</p>
) : data ? (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr 1fr",
gap: 20,
}}
>
{data.map((user) => (
<div
key={user.id}
style={{ border: "1px solid #ccc", textAlign: "center" }}
>
<img
src={`https://robohash.org/${user.id}?set=set2&size=180x180`}
alt={user.name}
style={{ height: 180, width: 180 }}
/>
<h3>{user.name}</h3>
</div>
))}
</div>
) : null}
</main>
);
}