이번 편의 결과물: books 저장 로직이 useLocalStorage 훅 하나로 교체됩니다. 다루는 개념: 컴포넌트에 흩어진 저장 로직 발견하기, 훅 추출 기준(입력·반환 계약), useLocalStorage(key, initialValue) 구현, JSON 직렬화 예외 처리
이 편에서 만드는 파일
bookshelf/src/
├── hooks/
│ └── useLocalStorage.js + 신규
└── pages/
├── BookListPage.jsx ~ useReducer+useEffect 조합을 useLocalStorage로 교체
└── NewBookPage.jsx ~ loadBooks/saveBooks를 useLocalStorage로 교체개념 정리
지금 bookshelf에는 같은 모양의 코드가 두 곳에 있습니다. BookListPage.jsx는 STORAGE_KEY 상수, localStorage.getItem으로 초기값을 읽는 함수, 값이 바뀔 때 localStorage.setItem을 호출하는 useEffect를 가지고 있습니다. NewBookPage.jsx도 05편에서 STORAGE_KEY, loadBooks, saveBooks라는 이름으로 거의 같은 코드를 반복했습니다. 같은 로직이 두 곳 이상에서 반복되면 커스텀 훅으로 뽑아낼 신호입니다.
| 판단 기준 | 이번 사례 적용 |
|---|---|
| 같은 로직이 두 곳 이상에서 필요한가 | BookListPage, NewBookPage 모두 같은 저장 키를 읽고 씀 |
| 입력과 반환이 명확히 정의되는가 | 입력: key, initialValue · 반환: useState와 동일한 [value, setValue] |
| 컴포넌트의 렌더링 로직과 분리 가능한가 | 저장 방식(localStorage)은 화면 렌더링과 무관함 |
useLocalStorage는 useState와 같은 모양으로 만듭니다. 반환값이 [value, setValue]이면 기존에 useState를 쓰던 자리에 이름만 바꿔 끼울 수 있어 학습 비용이 낮습니다. 이 원칙을 훅 계약을 기존 API에 맞춘다라고 부릅니다.
localStorage에 저장된 값은 항상 문자열입니다. 객체나 배열을 넣고 꺼내려면 JSON.stringify/JSON.parse가 필요하고, 저장된 값이 깨져 있거나 없을 때를 대비한 예외 처리가 필요합니다. 이 두 가지를 훅 내부에 감추는 것이 이번 편의 핵심입니다.
NewBookPage의 submitNewBook은 컴포넌트 함수 안에 정의된 클로저입니다. 라우트 loader(04편)처럼 컴포넌트 트리 밖에서 실행되는 함수가 아니므로, 컴포넌트 맨 위에서 부른 useLocalStorage의 books·setBooks를 그대로 가져다 쓸 수 있습니다. 반면 04편의 bookDetailLoader는 모듈 최상위에서 내보낸 별도 함수라 훅을 쓸 수 없고, 지금처럼 localStorage를 직접 읽는 방식을 유지합니다.
실습
1. 파일 만들기
src/hooks/useLocalStorage.js를 새로 만듭니다.
2. 코드 작성
// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const raw = localStorage.getItem(key);
if (raw === null) {
return typeof initialValue === 'function' ? initialValue() : initialValue;
}
try {
return JSON.parse(raw);
} catch {
return typeof initialValue === 'function' ? initialValue() : initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}초기값을 useState의 초기화 함수처럼 함수로도 받을 수 있게 했습니다. 계산 비용이 있는 기본값을 매 렌더링마다 다시 만들지 않기 위해서입니다.
BookListPage.jsx에 적용합니다.
// src/pages/BookListPage.jsx (일부)
import { useLocalStorage } from '../hooks/useLocalStorage.js';
import { booksReducer } from '../reducers/booksReducer.js';
// ...기존 import 유지 (useReducer, loadInitialBooks 관련 import는 지움)
export default function BookListPage() {
const [books, setBooks] = useLocalStorage('bookshelf-books', []);
// ...기존 filter, theme 등 다른 state 유지
function handleAdd(book) {
setBooks((prevBooks) => booksReducer(prevBooks, { type: 'added', payload: book }));
}
function handleToggle(id) {
setBooks((prevBooks) => booksReducer(prevBooks, { type: 'toggled', payload: { id } }));
}
function handleDelete(id) {
setBooks((prevBooks) => booksReducer(prevBooks, { type: 'deleted', payload: { id } }));
}
// ...기존 JSX 반환 유지. onToggle={handleToggle}, onDelete={handleDelete}로 연결
}이전에 있던 STORAGE_KEY 상수, loadInitialBooks 함수, useReducer, localStorage.setItem을 부르던 useEffect는 모두 지웁니다. booksReducer.js 파일 자체는 순수 함수라 그대로 재사용합니다. setBooks의 갱신 함수 형태((prevBooks) => ...)로 리듀서를 호출하는 점만 달라집니다.
NewBookPage.jsx도 같은 방식으로 정리합니다.
// src/pages/NewBookPage.jsx (일부)
import { useActionState } from 'react';
import { useNavigate } from 'react-router';
import { useLocalStorage } from '../hooks/useLocalStorage.js';
// STORAGE_KEY, loadBooks, saveBooks는 지웁니다.
const COVER_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2'];
const initialState = { error: null };
export default function NewBookPage() {
const navigate = useNavigate();
const [books, setBooks] = useLocalStorage('bookshelf-books', []);
async function submitNewBook(prevState, formData) {
const title = formData.get('title').trim();
const author = formData.get('author').trim();
if (title === '' || author === '') {
return { error: '제목과 저자는 반드시 입력합니다.' };
}
const nextId = books.length === 0 ? 1 : Math.max(...books.map((book) => book.id)) + 1;
const pagesInput = Number(formData.get('pages'));
const newBook = {
id: nextId,
title,
author,
status: formData.get('status'),
rating: 0,
pages: Number.isNaN(pagesInput) ? 0 : pagesInput,
coverColor: COVER_COLORS[Math.floor(Math.random() * COVER_COLORS.length)],
startedAt: null,
finishedAt: null,
memo: formData.get('memo').trim(),
};
setBooks([...books, newBook]);
navigate('/');
return { error: null };
}
const [state, formAction, isPending] = useActionState(submitNewBook, initialState);
// ...기존 JSX 유지
}3. 실행
npm run dev4. 확인
- 목록 페이지가 이전과 똑같이 보입니다(화면 변화 없음이 정상입니다).
- 책 상태를 토글하거나 삭제한 뒤 새로고침해도 값이 유지됩니다.
/books/new에서 책을 추가하고 목록으로 돌아오면 새 책이 보입니다.- 개발자 도구 애플리케이션 탭의 Local Storage에서
bookshelf-books키 하나만 있는지 확인합니다.
직접 해보기
useLocalStorage를 테마 값에도 적용해 보세요.ThemeContext.jsx의useState('light')를useLocalStorage('bookshelf-theme', 'light')로 바꾸면 새로고침해도 테마가 유지됩니다.- 개발자 도구에서
localStorage.setItem('bookshelf-books', '{잘못된 JSON')을 직접 실행한 뒤 페이지를 새로고침해 보세요. 앱이 죽지 않고 빈 목록으로 복구되는지 확인하세요.
풀이 보기
// src/context/ThemeContext.jsx (일부)
import { useLocalStorage } from '../hooks/useLocalStorage.js';
function ThemeProvider({ children }) {
const [theme, setTheme] = useLocalStorage('bookshelf-theme', 'light');
// ...기존 Provider value 유지
}두 번째 과제는 useLocalStorage의 try { JSON.parse(raw) } catch { return initialValue } 부분이 있어 자동으로 복구됩니다. 별도 코드 수정이 필요 없다는 것을 확인하는 것이 목적입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 새로고침할 때마다 값이 비어 보임 | key 문자열을 두 파일에서 다르게 씀(오타) | 'bookshelf-books' 문자열을 두 파일 모두 동일하게 쓰기 |
| 저장이 무한 반복되며 렌더링이 느려짐 | initialValue로 매번 새 배열·객체 리터럴을 넘김 | 초기값을 함수(() => [])로 넘기거나 컴포넌트 밖 상수 사용 |
/books/new에서 추가한 책이 목록에 안 보임 | 두 파일이 다른 저장 키를 사용 | BookListPage, NewBookPage 모두 'bookshelf-books'인지 확인 |
| 콘솔에 JSON 파싱 에러가 그대로 출력됨 | try/catch 없이 JSON.parse를 직접 호출 | 훅 내부의 try/catch가 지워지지 않았는지 확인 |