레슨 8 / 9·25분
실전: 메모 앱 만들기
프로젝트 구조
지금까지 배운 React의 핵심 개념(컴포넌트, State, Props, Hooks, Context)을 활용해 메모 앱을 만듭니다.
- •메모 추가, 수정, 삭제 기능
- •검색 필터링
- •Context로 상태 관리
- •localStorage 영속화
- •컴포넌트 분리와 타입 안전성
타입과 Context 정의
tsx
// types.ts
type Memo = {
id: string;
title: string;
content: string;
createdAt: number;
updatedAt: number;
};
// MemoContext.tsx
import { createContext, useContext, useReducer, useEffect } from 'react';
type Action =
| { type: 'ADD'; payload: { title: string; content: string } }
| { type: 'UPDATE'; payload: { id: string; title: string; content: string } }
| { type: 'DELETE'; payload: string }
| { type: 'LOAD'; payload: Memo[] };
function memoReducer(state: Memo[], action: Action): Memo[] {
switch (action.type) {
case 'ADD':
return [...state, {
id: crypto.randomUUID(),
...action.payload,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
case 'UPDATE':
return state.map(m =>
m.id === action.payload.id
? { ...m, ...action.payload, updatedAt: Date.now() }
: m
);
case 'DELETE':
return state.filter(m => m.id !== action.payload);
case 'LOAD':
return action.payload;
default:
return state;
}
}Provider 구현
tsx
const MemoContext = createContext<{
memos: Memo[];
dispatch: React.Dispatch<Action>;
}>({ memos: [], dispatch: () => {} });
export function MemoProvider({ children }: { children: React.ReactNode }) {
const [memos, dispatch] = useReducer(memoReducer, []);
// 초기 로드
useEffect(() => {
const saved = localStorage.getItem('memos');
if (saved) {
dispatch({ type: 'LOAD', payload: JSON.parse(saved) });
}
}, []);
// 변경 시 저장
useEffect(() => {
localStorage.setItem('memos', JSON.stringify(memos));
}, [memos]);
return (
<MemoContext.Provider value={{ memos, dispatch }}>
{children}
</MemoContext.Provider>
);
}
export const useMemos = () => useContext(MemoContext);메모 목록 컴포넌트
tsx
function MemoList() {
const { memos, dispatch } = useMemos();
const [query, setQuery] = useState('');
const filtered = useMemo(() =>
memos
.filter(m =>
m.title.includes(query) || m.content.includes(query)
)
.sort((a, b) => b.updatedAt - a.updatedAt),
[memos, query]
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="메모 검색..."
/>
<p>{filtered.length}개의 메모</p>
{filtered.map(memo => (
<div key={memo.id} className="memo-card">
<h3>{memo.title}</h3>
<p>{memo.content.slice(0, 100)}</p>
<small>
{new Date(memo.updatedAt).toLocaleString('ko-KR')}
</small>
<button onClick={() =>
dispatch({ type: 'DELETE', payload: memo.id })
}>
삭제
</button>
</div>
))}
</div>
);
}메모 작성 폼
tsx
function MemoForm({ editMemo }: { editMemo?: Memo }) {
const { dispatch } = useMemos();
const [title, setTitle] = useState(editMemo?.title || '');
const [content, setContent] = useState(editMemo?.content || '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
if (editMemo) {
dispatch({
type: 'UPDATE',
payload: { id: editMemo.id, title, content },
});
} else {
dispatch({
type: 'ADD',
payload: { title, content },
});
}
setTitle('');
setContent('');
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="제목"
required
/>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="내용을 입력하세요"
rows={5}
/>
<button type="submit">
{editMemo ? '수정' : '추가'}
</button>
</form>
);
}
// App.tsx
function App() {
return (
<MemoProvider>
<div className="app">
<h1>메모 앱</h1>
<MemoForm />
<MemoList />
</div>
</MemoProvider>
);
}💡
이 프로젝트에서 사용한 패턴(Context + useReducer + 커스텀 Hook)은 중소 규모 React 앱에서 가장 많이 사용되는 상태 관리 패턴입니다.