Skip to Content
WebReactReact 중급06. 커스텀 훅 추출: useLocalStorage

이번 편의 결과물: books 저장 로직이 useLocalStorage 훅 하나로 교체됩니다. 다루는 개념: 컴포넌트에 흩어진 저장 로직 발견하기, 훅 추출 기준(입력·반환 계약), useLocalStorage(key, initialValue) 구현, JSON 직렬화 예외 처리

이 편에서 만드는 파일

bookshelf/src/ ├── hooks/ │ └── useLocalStorage.js + 신규 └── pages/ ├── BookListPage.jsx ~ useReducer+useEffect 조합을 useLocalStorage로 교체 └── NewBookPage.jsx ~ loadBooks/saveBooks를 useLocalStorage로 교체

개념 정리

지금 bookshelf에는 같은 모양의 코드가 두 곳에 있습니다. BookListPage.jsxSTORAGE_KEY 상수, localStorage.getItem으로 초기값을 읽는 함수, 값이 바뀔 때 localStorage.setItem을 호출하는 useEffect를 가지고 있습니다. NewBookPage.jsx도 05편에서 STORAGE_KEY, loadBooks, saveBooks라는 이름으로 거의 같은 코드를 반복했습니다. 같은 로직이 두 곳 이상에서 반복되면 커스텀 훅으로 뽑아낼 신호입니다.

판단 기준이번 사례 적용
같은 로직이 두 곳 이상에서 필요한가BookListPage, NewBookPage 모두 같은 저장 키를 읽고 씀
입력과 반환이 명확히 정의되는가입력: key, initialValue · 반환: useState와 동일한 [value, setValue]
컴포넌트의 렌더링 로직과 분리 가능한가저장 방식(localStorage)은 화면 렌더링과 무관함

useLocalStorageuseState같은 모양으로 만듭니다. 반환값이 [value, setValue]이면 기존에 useState를 쓰던 자리에 이름만 바꿔 끼울 수 있어 학습 비용이 낮습니다. 이 원칙을 훅 계약을 기존 API에 맞춘다라고 부릅니다.

localStorage에 저장된 값은 항상 문자열입니다. 객체나 배열을 넣고 꺼내려면 JSON.stringify/JSON.parse가 필요하고, 저장된 값이 깨져 있거나 없을 때를 대비한 예외 처리가 필요합니다. 이 두 가지를 훅 내부에 감추는 것이 이번 편의 핵심입니다.

NewBookPagesubmitNewBook은 컴포넌트 함수 안에 정의된 클로저입니다. 라우트 loader(04편)처럼 컴포넌트 트리 밖에서 실행되는 함수가 아니므로, 컴포넌트 맨 위에서 부른 useLocalStoragebooks·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 dev

4. 확인

  • 목록 페이지가 이전과 똑같이 보입니다(화면 변화 없음이 정상입니다).
  • 책 상태를 토글하거나 삭제한 뒤 새로고침해도 값이 유지됩니다.
  • /books/new에서 책을 추가하고 목록으로 돌아오면 새 책이 보입니다.
  • 개발자 도구 애플리케이션 탭의 Local Storage에서 bookshelf-books 키 하나만 있는지 확인합니다.

직접 해보기

  1. useLocalStorage를 테마 값에도 적용해 보세요. ThemeContext.jsxuseState('light')useLocalStorage('bookshelf-theme', 'light')로 바꾸면 새로고침해도 테마가 유지됩니다.
  2. 개발자 도구에서 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 유지 }

두 번째 과제는 useLocalStoragetry { JSON.parse(raw) } catch { return initialValue } 부분이 있어 자동으로 복구됩니다. 별도 코드 수정이 필요 없다는 것을 확인하는 것이 목적입니다.

자주 하는 실수

증상원인고치는 법
새로고침할 때마다 값이 비어 보임key 문자열을 두 파일에서 다르게 씀(오타)'bookshelf-books' 문자열을 두 파일 모두 동일하게 쓰기
저장이 무한 반복되며 렌더링이 느려짐initialValue로 매번 새 배열·객체 리터럴을 넘김초기값을 함수(() => [])로 넘기거나 컴포넌트 밖 상수 사용
/books/new에서 추가한 책이 목록에 안 보임두 파일이 다른 저장 키를 사용BookListPage, NewBookPage 모두 'bookshelf-books'인지 확인
콘솔에 JSON 파싱 에러가 그대로 출력됨try/catch 없이 JSON.parse를 직접 호출훅 내부의 try/catch가 지워지지 않았는지 확인

확인 문제

문제 14지선다
useLocalStorage를 useState와 같은 반환 모양([value, setValue])으로 만든 이유는
문제 24지선다
04편의 bookDetailLoader는 이번 편에서 만든 useLocalStorage로 바꾸지 않고 그대로 둔 이유는
문제 34지선다
useLocalStorage 내부에서 JSON.parse를 try/catch로 감싼 이유는

참고 자료

Last updated on