이번에 공부할 것은 타입스크립트 환경에서 Context API를 제대로 활용하는 방법에 대해서 알아보도록 하겠습니다. Context API를 사용함에 있어서, 코드의 구조를 어떻게 가져갈 ㅣ젱 대해서는 딱 정해진 방법이 존재하지 않습니다. Context 를 준비하는 과정에서 클래스형 컴포넌트를 사용할 수도 있고, 함수형 컴포넌트를 사용할 수도 있겠죠.
Context API를 활용하는 여러가지 방법 중에서, 편하다고 생각하는 방식을 알아보도록 하겠습니다. (본 포스팅은 벨로퍼트님의 리액트 타입스크립트 실습을 공부한 것입니다.)
먼저, Context API를 연습해볼 타입스크립트가 적용된 리액트 프로젝트를 준비해주세요.
$ npx create-react-app ts-context-api-tutorial --template typescript
먼저, Context API 를 활용하여 투두리스트를 만들어보겠습니다.
Context API를 활용하기 전에, 컴포넌트 몇가지를 미리 만들어볼건데요.
만들 컴포넌트들은 다음과 같습니다.
TodoForm.tsx : 새 할 일을 등록할 때 사용
TodoItem.tsx : 할 일에 대한 정보를 보여줌
TodoList.tsx : 여러 TodoItem들을 렌더링해줌
src 디렉터리에 components 디렉터리를 만들고, 그 안에 위 컴포넌트들을 하나씩 하나씩 만들어 보겠습니다.
TodoForm 컴포넌트는 새 항목을 등록 할 수 있는 컴포넌트입니다. 이 컴포넌트에서는 하나의 input과 button을 렌더링 해주세요. input의 value 값은 useState를 통해서 관리하도록 하겠습니다. 추가적으로, submit 이벤트가 발생 했을 때에는 새 항목을 생성하고 value 값을 초기화 해줄 건데요, 새 항목을 생성하는 부분은 추후 구현해주겠습니다.
import React, { useState } from 'react';
function TodoForm() {
const [value, setValue] = useState('');
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// TODO : 새 항목 생성하기
setValue('');
};
return (
<form onSubmit={onSubmit}>
<input value={value} placeholder="무엇을 하실 건가요?" onChange={e => setValue(e.target.value)} />
<button>등록</button>
</form>
);
}
export default TodoForm;
TodoItem 컴포넌트는 할 일 항목에 대한 정보를 보여주는 컴포넌트입니다. props 로는 todo 객체를 받아옵니다. 만약 todo.done 값이 참이라면 CSS 클래스를 적용합니다.
import React from 'react';
import './TodoItem.css';
export type TodoItemProps = {
todo: {
id: number;
text: string;
done: boolean;
};
}
function TodoItem({ todo }: TodoItemProps) {
return (
<li className={`TodoItem ${todo.done ? 'done' : ''}`}>
<span className="text">{todo.text}</span>
<span className="remove">(X)</span>
</li>
);
}
export default TodoItem;
위 컴포넌트에 대한 css 파일도 작성해주세요.
.TodoItem .text {
cursor: pointer;
}
.TodoItem.done .text {
color: #999999;
text-decoration: line-through;
}
.TodoItem .remove {
color: red;
margin-left: 4px;
cursor: pointer;
}
이제 마지막 컴포넌트인 TodoList 컴포넌트를 작성해 줍시다. 이 컴포넌트에서는 todos라는 배열을 사용하여 여러개의 TodoItems 컴포넌트를 렌더링해주는 작업을 해줄건데요. 아직은 이 배열에 대한 상태가 존재하지 않으므로 이 배열을 임시적으로 TodoList 내부에서 선언하도록 하겠습니다.
import React from 'react';
import TodoItem from './TodoItem';
function TodoList() {
const todos = [
{
id: 1,
text: 'Context API 배우기',
done: true
},
{
id: 2,
text: 'TypeScript 배우기',
done: true
},
{
id: 3,
text: 'TypeScript와 Context API 배우기',
done: false
}
];
return (
<ul>
{todos.map(todo => (
<TodoItem todo={todo} key={todo.id} />
))}
</ul>
);
}
export default TodoList;
이제 우리가 만든 컴포넌트를 App에서 렌더링 해봅시다.
import React from 'react';
import TodoForm from './components/TodoForm';
import TodoList from './components/TodoList';
const App = () => {
return (
<>
<TodoForm />
<TodoList />
</>
);
};
export default App;
다음과 같은 결과가 나타났나요?
그럼 이제 본격적으로 Context API를 사용해봅시다. src 디렉터리에 contexts 디렉터리를 만들고, 그 안에 TodosContext.tsx 파일을 생성해 보세요.
우리는 TodosContext.tsx 파일 안에 2개의 Context 를 만들어 보도록 하겠습니다. 하나는 상태 전용 Context이고, 또 다른 하나는 디스패치 전용 Context 입니다. 이렇게 두 개의 Context를 만들면 낭비 렌더링을 방지할 수 있습니다.
만약 상태와 디스패치 함수를 한 Context에 넣게 된다면, TodoForm 컴포넌트처럼 상태는 필요하지 않고 디스패치 함수만 필요한 컴포넌트도 상태가 업데이트 될 때 리렌더링하게 됩니다. 두 개의 Context를 만들어서 관리한다면 이를 방지 할 수 있습니다.
먼저 상태 전용 Context를 선언해 보겠습니다.
import { createContext } from 'react';
// 나중에 다른 컴포넌트에서 타입을 불러와서 쓸 수 있도록 내보내겠습니다. (export 키워드로 내보냄)
export type Todo = {
id: number;
text: string;
done: boolean;
};
type TodosState = Todo[];
const TodosStateContext = createContext<TodosState | undefined>(undefined);
Context 를 만들 때는 위 코드와 같이 createContext 함수의 Generics를 사용하여 Context에서 관리할 값의 상태를 설정해줄 수 있는데요. 우리가 추후 Provider를 사용하지 않았을 때에는 Context의 값이 undefined 가 되어야 하므로, <TodosState | undefined>와 같이 Context 의 값이 TodosState 일 수도 있고 undefined 일 수도 있다고 선언을 해주세요.
그 다음엔, 액션들을 위한 타입스크립트 타입들을 선언해주세요. 우리는 총 3가지의 액션들을 만들 것입니다.
import { createContext } from 'react';
// 나중에 다른 컴포넌트에서 타입을 불러와서 쓸 수 있도록 내보내겠습니다. (export 키워드로 내보냄)
export type Todo = {
id: number;
text: string;
done: boolean;
};
type TodosState = Todo[];
const TodosStateContext = createContext<TodosState | undefined>(undefined);
type Action =
| { type: 'CREATE'; text: string }
| { type: 'TOGGLE'; id: number }
| { type: 'REMOVE'; id: number };
이렇게 액션들의 타입을 선언해주고 나면, 우리가 디스패치를 위한 Context를 만들 대 디프새치 함수의 타입을 설정 할 수 있게 됩니다.
import { createContext, Dispatch } from 'react';
// 나중에 다른 컴포넌트에서 타입을 불러와서 쓸 수 있도록 내보내겠습니다. (export 키워드로 내보냄)
export type Todo = {
id: number;
text: string;
done: boolean;
};
type TodosState = Todo[];
const TodosStateContext = createContext<TodosState | undefined>(undefined);
type Action =
| { type: 'CREATE'; text: string }
| { type: 'TOGGLE'; id: number }
| { type: 'REMOVE'; id: number };
type TodosDispatch = Dispatch<Action>;
const TodosDispatchContext = createContext<TodosDispatch | undefined>(undefined);
이렇게 Dispatch 를 리액트 패키지에서 불러와서 Generic으로 액션들의 타입을 넣어주면 추후 컴포넌트에서 액션을 디스패치 할 때 액션들에 대한 타입을 검사할 수 있습니다. 예를 들어서, 액션에 추가적으로 필요한 값 (예 : text, id)이 빠지면 오류가 발생하죠.
이제 할 일 관리를 위한 리듀서를 만들어봅시다!
import { createContext, Dispatch } from 'react';
// 나중에 다른 컴포넌트에서 타입을 불러와서 쓸 수 있도록 내보내겠습니다. (export 키워드로 내보냄)
export type Todo = {
id: number;
text: string;
done: boolean;
};
type TodosState = Todo[];
const TodosStateContext = createContext<TodosState | undefined>(undefined);
type Action =
| { type: 'CREATE'; text: string }
| { type: 'TOGGLE'; id: number }
| { type: 'REMOVE'; id: number };
type TodosDispatch = Dispatch<Action>;
const TodosDispatchContext = createContext<TodosDispatch | undefined>(undefined);
function todosReducer(state: TodosState, action: Action): TodosState {
switch (action.type) {
case 'CREATE':
const nextId = Math.max(...state.map(todo => todo.id)) + 1;
return state.concat({
id: nextId,
text: action.text,
done: false
});
case 'TOGGLE':
return state.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case 'REMOVE':
return state.filter(todo => todo.id !== action.id);
default:
throw new Error('Unhandled Action');
}
}
이렇게 타입스크립트를 사용하여 리듀서를 작성하는 과정에서는 다음과 같이 액션의 type 값이 자동완성도 되고
액션의 type에 따라 그 액션 객체의 구조가 어떻게 구성되었는지도 바로 IDE 상에서 알 수 있습니다.
이제 TodosStateContext와 TodosDispatchContext의 Provider를 함께 사용하는 TodosProvider라는 컴포넌트를 준비해 보겠습니다.
import React, { createContext, Dispatch, useReducer } from 'react';
(...) // (이전 코드 생략)
export function TodosContextProvider({ children }: { children: React.ReactNode }) {
const [todos, dispatch] = useReducer(todosReducer, [
{
id: 1,
text: 'Context API 배우기',
done: true
},
{
id: 2,
text: 'TypeScript 배우기',
done: true
},
{
id: 3,
text: 'TypeScript 와 Context API 함께 사용하기',
done: false
}
]);
return (
<TodosDispatchContext.Provider value={dispatch}>
<TodosStateContext.Provider value={todos}>
{children}
</TodosStateContext.Provider>
</TodosDispatchContext.Provider>
);
}
우리가 방금 만든 컴포넌트는 나중에 App에서 불러와서 기존 내용을 감싸주어야 하므로 내보내주어야 합니다.
자, 이제 Context 준비는 거의 다 끝났습니다.
우리가 추후 TodosStatecontext와 TodosDispatchContext를 사용하게 될 때는 다음과 같이 useContext 를 사용해서 Context 안의 값을 사용할 수 있습니다.
const todos = useContext(TodosStateContext);
그런데 이 때 todos의 타입은 TodosState | undefined 일 수도 있습니다. 따라서, 해당 배열을 사용하기 전에 꼭 해당 값이 유효한지 확인을 해 주어야 하죠.
const todos = useContext(TodosStateContext);
if (!todo) return null;
그렇게 해도 상관은 없지만.. 더 좋은 방법은 TodosContext 전용 Hooks를 작성해서 조금 더 편리하게 사용하는 것 입니다. 다음과 같이 코드를 작성하면 추후 상태 또는 디스패치를 더욱 편하게 이용 할 수도 있고, 컴포너트에서 사용 할 때 유효성 검사도 생략 할 수 있습니다.
커스텀 Hooks 를 작성해봅시다.
(...) // 기존 코드 생략
export function useTodosState() {
const state = useContext(TodosStateContext);
if (!state) throw new Error('TodosProvider not found');
return state;
}
export function useTodosDispatch() {
const dispatch = useContext(TodosDispatchContext);
if (!dispatch) throw new Error('TodosProvider not found');
return dispatch;
}
이렇게 만약 함수 내부에서 필요한 값이 유효하지 않다면 에러를 throw 하여 각 Hook이 반환하는 값의 타입은 언제나 유효하다는 것을 보장 받을 수 있습니다. (만약 유효하지 않다면 브라우저의 콘솔에 에러가 바로 나타납니다.)
이제 Context 작성이 드디어 모두 끝났습니다. useTodosState와 useTodosDispatch는 다른 컴포넌트에서 불러와서 사용 할 수 있도록 내보내주셔야 합니다. (export 키워드로)
이제 우리가 만든 Context를 사용해줄 차례입니다.
가장 먼저 해야 할 작업은 App 컴포넌트에서 TodosContextProvider를 불러와서 기존 내용을 감싸주는 것입니다.
import React from 'react';
import TodoForm from './components/TodoForm';
import TodoList from './components/TodoList';
import { TodosContextProvider } from './contexts/TodosContext';
const App = () => {
return (
<TodosContextProvider>
<TodoForm />
<TodoList />
</TodosContextProvider>
);
};
export default App;
그 다음에는 TodoList 컴포넌트에서 Context 안의 상태를 조회하여 내용을 렌더링해보겠습니다. 우리가 커스텀 Hook을 만들었기 때문에 정말로 간단하게 처리할 수 있습니다.
import React from 'react';
import TodoItem from './TodoItem';
import { useTodosState } from '../contexts/TodosContext';
function TodoList() {
const todos = useTodosState();
return (
<ul>
{todos.map(todo => (
<TodoItem todo={todo} key={todo.id} />
))}
</ul>
);
}
export default TodoList;
그냥 usetodosState 를 불러와서 호출하기만 하면 현재 상태를 조회할 수 있습니다.
이제 TodoForm에서 새 항목을 등록하는 작업을 구현해봅시다. useTodosDispatch Hook 을 통해 dispatch 함수를 받아오고, 액션을 디스패치해보세요.
import React, { useState } from 'react';
import { useTodosDispatch } from '../contexts/TodosContext';
function TodoForm() {
const [value, setValue] = useState('');
const dispatch = useTodosDispatch();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
dispatch({
type: 'CREATE',
text: value
});
setValue('');
};
return (
<form onSubmit={onSubmit}>
<input value={value} placeholder="무엇을 하실 건가요?" onChange={e => setValue(e.target.value)} />
<button>등록</button>
</form>
);
}
export default TodoForm;
액션을 디스패치하는 코드를 작성하는 과정에서, 액션의 type 이 자동완성도 되고, 액션에 필요한 값이 빠져있다면 타입검사를 통해 오류를 보여줍니다.
여기까지 코드를 다 작성했다면, 새로운 항목이 잘 등록되는지 직접 입력해서 등록해보세요.
이번에는 TodoItem 에서 항목을 클릭했을 때 done 값을 토글하고, 우측의 (X) 를 눌렀을 때 제거하는 기능을 구현해보겠습니다. 아마 일반적으로 Context를 사용하지 않는 환경에서는 TodoItem 컴포넌트에서 onToggle, onRemove props 를 가져와서 이를 호출하는 형태로 구현 할 것 입니다. 하지만 지금과 같이 Context를 사용하고 있다면 굳이 이 함수들을 props를 통해서 가져오지 않고, 이 컴포넌트 내부에서 바로 액션을 디스패치하는 것도 꽤나 괜찮은 방법입니다.
import React from 'react';
import './TodoItem.css';
import { useTodosDispatch, Todo } from '../contexts/TodosContext';
export type TodoItemProps = {
todo: Todo; // TodoContext에서 선언했던 타입을 불러왔습니다.
}
function TodoItem({ todo }: TodoItemProps) {
const dispatch = useTodosDispatch();
const onToggle = () => {
dispatch({
type: 'TOGGLE',
id: todo.id
});
};
const onRemove = () => {
dispatch({
type: 'REMOVE',
id: todo.id
})
};
return (
<li className={`TodoItem ${todo.done ? 'done' : ''}`}>
<span className="text" onClick={onToggle}>{todo.text}</span>
<span className="remove" onClick={onRemove}>(X)</span>
</li>
);
}
export default TodoItem;
이제 TodoItem 의 구현도 모두 끝났습니다! 브라우저에서 TodoItem의 항목의 done 상태를 토글도 해보고, 항목을 삭제도 해보세요.
이번 공부에서는 우리가 타입스크립트 환경에서 리액트의 Context API를 효과적으로 활용하는 방법에 대해서 알아보았습니다. Context API를 사용하여 상태를 관리할 때 useReducer 를 사용하고 상태 전용 Context와 디스패치 함수 전용 Context 를 만들면 매우 유용합니다. 추가적으로, Context를 만들고 나서 해당 Context를 쉽게 사용 할 수 있는 커스텀 Hooks를 작성하면 더욱 편하게 개발을 할 수 있습니다.