Todo앱 만들기(중단)에서 중단했던 Todo앱을 Redux-toolkit으로 다시 만들고자 한다.
기본 셋팅은 영화앱2: 개발 환경을 따른다.
conponents/Header.tsx를 만든다
import React from "react";
const Header: React.FC = () => {
return <h1>개쩌는 투두앱</h1>;
};
export default Header;
function App({ Component, pageProps }: AppProps) {
return (
<>
<GlobalStyle />
<Header />
<Component {...pageProps} />
</>
);
}
export default App;
import React from "react";
const TodoList:React.FC = () => {
return <div>TodoList</div>;
};
export default TodoList;
import Head from "next/head";
import TodoList from "../components/TodoList";
export default function Home() {
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link
rel="stylesheet"
type="text/css"
href="https://cdn.jsdelivr.net/gh/moonspam/NanumSquare@2.0/nanumsquare.css"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<TodoList />
</>
);
}
잘 출력된다
export type TodoType = {
id: number;
text: string;
color: "RED" | "ORANGE" | "YELLOW";
checked: boolean;
};
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TodoType } from "../types/todo";
const todoSlice = createSlice({
name: "todoSlice",
initialState: {
todos: [] as TodoType[]
},
reducers: {
setTodo(state, action: PayloadAction<TodoType>){
state.todos.push(action.payload)
}
}
});
export default todoSlice;
책의 예제 코드와는 다른 부분이 많은데
1. todos라는 프로퍼티를 선언할 때에 타입 앨리어싱으로 새로운 타입을 주입했다. - 리덕스 툴킷에서 이미 slice의 타입을 지정해두었기 때문에 타입 앨리어싱이 가능하다.
2. reducers는 state.todos를 가변적으로 다루었다. - 리덕스 툴킷에서 불변성을 지켜주기 때문에 가변적 액션이 가능하다.
3. 별도의 actions는 export하지 않았다.
서버사이드에서 사용되는 Store와 클라이언트사이드에서 사용되는 Store가 다르다. 서버사이드에서 Store를 변경하고, 이를 클라이언트 사이드로 주입하는 과정이 필요한데, next-redux-wrapper에서는 HYDRATE를 통해 이를 처리해준다.
HYDRATE를 적용하기 위해 기존 리덕스는 rootReducer에 Action.type이 HYDRATE인 경우를 처리해주는데, rootReducer를 생성하는 것은 리덕스 툴킷 답지 못하다. Slice의 extraReducers에 HYDRATE를 추가해주자. (두 방법의 예제 코드는 이 블로그에 잘 정리되어있다.)
store/todoSlice.tsx
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TodoType } from "../types/todo";
import { HYDRATE } from "next-redux-wrapper";
const todoSlice = createSlice({
name: "todoSlice",
initialState: {
todos: [] as TodoType[],
},
reducers: {
setTodo(state, action: PayloadAction<TodoType>) {
state.todos.push(action.payload);
},
},
extraReducers: {
[HYDRATE]: (state, action) => {
return {
...state,
...action.payload,
};
},
},
});
export default todo;
import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import todoSlice from "./todoSlice";
const makeStore = () =>
configureStore({
reducer: {
todoSlice: todoSlice.reducer,
},
});
const wrapper = createWrapper(makeStore);
export default wrapper;
import type { AppProps } from "next/app";
import GlobalStyle from "../styles/GlobalStyle";
import Header from "../components/Header";
import wrapper from "../store";
import { Provider } from "react-redux";
function App({ Component, pageProps }: AppProps) {
const { store, props } = wrapper.useWrappedStore(pageProps);
return (
<Provider store={store}>
<GlobalStyle />
<Header />
<Component {...props} />
</Provider>
);
}
export default App;
next js 8버전 이상부터 useWrappedStore(pageProps)방식이 사용된다.
useWrappedStore는 pageProps를 인자로 받아 makeStore함수가 반환한 store와 pageProps를 store와 연동시킨 props를 반환한다.
store는 리액트-리덕스의 Provider의 store로 넘겨주고,
props는 Component에 pageProps 대신 넘겨준다.
useSelecor는 기본적으로 타입이 없어 타입스크립트와 연동을 하면 빨간줄을 내뱉는다. 리액트-리덕스의 TypedUseSelectorHook<RootState>
를 이용하여 타입을 씌워줄 수 있다.
TypedUseSeletorHook을 사용하기 위해서는 RootState를 알아야 한다. next js 공식문서의 방식(RootState = ReturnType<typeof store.getState>;
)을 따라 아래와 같이 수정했다.
import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import {
TypedUseSelectorHook,
useSelector as useReduxSeletor,
} from "react-redux";
import todoSlice from "./todoSlice";
const store = configureStore({
reducer: {
todoSlice: todoSlice.reducer,
},
});
const makeStore = () => store;
const wrapper = createWrapper(makeStore);
export default wrapper;
//RootState 타입은 store의 getState의 결과값의 타입과 같음.
export type RootState = ReturnType<typeof store.getState>;
export const useSelector: TypedUseSelectorHook<RootState> = useReduxSeletor;
import React from "react";
import { useSelector } from "../store";
const TodoList: React.FC = () => {
const todos = useSelector((state) => state.todoSlice.todos);
return (
<div>
<h1>TodoList</h1>
<p>남은 TODO {todos.length}개</p>
<ul>
{todos.map((todo, index) => {
return (
<li key={todo.id}>
<span>{todo.checked ? "O" : "X"}</span>
<span>{todo.text}</span>
<span>{todo.color}</span>
</li>
);
})}
</ul>
</div>
);
};
export default TodoList;
임의로 store/todoSlice.tsx의 initialState를 변경하고,
initialState: {
todos: [
{ id: 1, text: "마트 가서 장보기", color: "RED", checked: false },
{ id: 2, text: "수학 숙제하기", color: "ORANGE", checked: true },
{ id: 3, text: "투두리스트 만들기", color: "YELLOW", checked: false },
{
id: 4,
text: "마트가서 투두리스트 만드는 숙제하기",
color: "RED",
checked: false,
},
] as TodoType[],
실행하면
잘 뜬다.
dispatch를 사용하기에 앞서 todoSlice의 reducers를 아래와 같이 수정하였다. redux-toolkit이 state의 불변성을 지켜주는 바, 아래와 같이 기존 객체를 직접 수정하는 방식으로 작성해보았다.
reducers: {
newTodo(state, action: PayloadAction<TodoType>) {
state.todos.push(action.payload);
},
deleteTodo(state, action: PayloadAction<TodoType>) {
console.log(state.todos.indexOf(action.payload));
state.todos.splice(
state.todos.findIndex((todo) => todo.id === action.payload.id),
1,
);
},
updateTodo(state, action: PayloadAction<TodoType>) {
state.todos.splice(
state.todos.findIndex((todo) => todo.id === action.payload.id),
1,
action.payload,
);
},
},
payload로 넘겨줄 state를 만든다.
const [newText, setNewText] = useState("");
const [newColor, setNewColor] = useState<TodoType["color"]>("RED");
form을 만든다
<form onSubmit={createTodo}>
<input
name="text"
type="text"
placeholder="할 일을 입력하세요"
onChange={(e) => setNewText(e.target.value)}
value={newText}
/>
<select name="color" onChange={onSelect} value={newColor}>
<option value="RED">RED</option>
<option value="ORANGE">ORANGE</option>
<option value="YELLOW">YELLOW</option>
</select>
<button type="submit">작성하기</button>
</form>
select 태그의 onChange 이벤트를 다루는 이벤트 핸들러를 만든다. select 이벤트를 onChange로 처리할 때에 타입 처리의 이슈가 있다. 스택 오버플로를 참조해봤는데..해결이 안됐다; 일단 그냥 진행.
const onSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value: TodoType["color"] = e.target.value;
setNewColor(value);
};
onSubmit 이벤트를 만든다.
const createTodo = (e: React.FormEvent) => {
e.preventDefault();
const newId = todos[todos.length - 1].id + 1;
dispatch(
todoSlice.actions.newTodo({
id: newId,
text: newText,
color: newColor,
checked: false,
}),
);
setNewText("");
};
새로운 할 일이 잘 추가된다.
리스트에 onClick 이벤트를 만든 후, todo 데이터를 인자로 넘겨준다.
<li key={todo.id} onClick={() => onChecked(todo)}>
넘겨받은 인자에서 checked 부분만 토글시켜 upatedTodo 액션을 dispatch한다.
const onChecked = (payload) => {
dispatch(
todoSlice.actions.updateTodo({
...payload,
checked: !payload.checked,
}),
);
};
잘 작동된다.
<button
type="button"
onClick={() => dispatch(todoSlice.actions.deleteTodo(todo))}
>
삭제하기
</button>
잘 작동한다.
완성된 코드는 아래와 같다.
// components/TodoList.tsx
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { useSelector } from "../store";
import todoSlice from "../store/todoSlice";
import { TodoType } from "../types/todo";
const TodoList: React.FC = () => {
const todos = useSelector((state) => state.todoSlice.todos);
const dispatch = useDispatch();
const [newText, setNewText] = useState("");
const [newColor, setNewColor] = useState<TodoType["color"]>("RED");
const onSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value: TodoType["color"] = e.target.value;
setNewColor(value);
};
const createTodo = (e: React.FormEvent) => {
e.preventDefault();
const newId = todos[todos.length - 1].id + 1;
dispatch(
todoSlice.actions.newTodo({
id: newId,
text: newText,
color: newColor,
checked: false,
}),
);
setNewText("");
};
const onChecked = (payload) => {
dispatch(
todoSlice.actions.updateTodo({
...payload,
checked: !payload.checked,
}),
);
};
return (
<div>
<h1>TodoList</h1>
<p>남은 TODO {todos.length}개</p>
<form onSubmit={createTodo}>
<input
name="text"
type="text"
placeholder="할 일을 입력하세요"
onChange={(e) => setNewText(e.target.value)}
value={newText}
/>
<select name="color" onChange={onSelect} value={newColor}>
<option value="RED">RED</option>
<option value="ORANGE">ORANGE</option>
<option value="YELLOW">YELLOW</option>
</select>
<button type="submit">작성하기</button>
</form>
<ul>
{todos.map((todo, index) => {
return (
<>
<li key={todo.id} onClick={() => onChecked(todo)}>
<span>{todo.checked ? "O" : "X"}</span>
<span>{todo.text}</span>
<span>{todo.color}</span>
</li>
<button
type="button"
onClick={() => dispatch(todoSlice.actions.deleteTodo(todo))}
>
삭제하기
</button>
</>
);
})}
</ul>
</div>
);
};
export default TodoList;