React_Day.7

조현기·4일 전

React_Day.7

props로 값을 여러 단계 내려보내는 불편함을 Context로 해결하고, 연습 문제 여러 개와 localStorage 저장, react-router-dom 맛보기까지 한 날.

Day.3부터 값과 setter를 props로 내려보내는 걸 반복했다. 구조가 깊어지면 이 방식이 번거로워진다. 오늘은 그걸 해결하는 Context가 주제다.


1. props로 내려보내기의 불편함

"관리자 여부(isAdmin)"를 App이 갖고 있고, 두 단계 아래 Modibutton이 그 값으로 버튼을 활성화/비활성화하는 예제다.

App (isAdmin state)
└─ Child1
     └─ Modibutton   ← 여기서 isAdmin이 필요하다
// App
const [isAdmin, setIsAdmin] = useState(false)
const onSwitch = () => { setIsAdmin(!isAdmin) }

return (
  <div>
    {isAdmin ? <p>관리자</p> : <p>관리자 아니다.</p>}
    <button onClick={onSwitch}>관리자 전원버튼</button>
    <Child1 isAdmin={isAdmin} />
  </div>
)
// Child1: 자기는 쓰지도 않는 isAdmin을 받아서 그대로 아래로 넘겨 준다
const Child1 = (props) => {
  const { isAdmin } = props
  return (
    <div style={style}>
      <p>{isAdmin.toString()}</p>
      <Modibutton isAdmin={isAdmin} />
    </div>
  )
}
// Modibutton: 실제로 쓰는 곳
const Modibutton = (props) => {
  const { isAdmin } = props
  return <button disabled={!isAdmin}>관리자만 볼 수 있음</button>
}

두 가지는 짚고 넘어간다.

  • {isAdmin.toString()}: false, undefined, null은 화면에 출력되지 않는 값이다. <p>{false}</p>는 빈 <p>가 된다. 그래서 toString()으로 문자열 "false"로 바꿔야 눈에 보인다.
  • disabled={!isAdmin}: disabled는 버튼을 비활성화하는 속성이다. 조건이 true일 때 비활성화되므로 관리자가 아니면(!isAdmin이 true) 눌리지 않는다.

문제는 Child1이다. 자기는 isAdmin을 쓰지 않는데도 아래로 전달하기 위해 받아야 한다. 단계가 3개, 4개로 늘어나면 중간 컴포넌트마다 props를 받아서 넘겨 주는 코드가 생긴다. 이걸 props drilling이라고 부른다.


2. Context: 공용 보관함

Context = 컴포넌트 트리 전체가 같이 쓰는 공용 보관함.
값을 넣는 쪽은 Provider, 꺼내 쓰는 쪽은 useContext.

세 단계로 쓴다.

(1) 보관함 만들기 + Provider 컴포넌트

// AdminProvider.jsx
import React, { createContext, useState } from 'react'

// Context 객체 생성 → 공용 보관함을 만든다
// 반드시 컴포넌트 밖, 최상단 레벨에 쓴다
export const AdminContext = createContext({})

const AdminProvider = (props) => {
  const { children } = props          // children = 이 컴포넌트가 감싸고 있는 <App />
  const [isAdmin, setIsAdmin] = useState(false)

  return (
    // 값을 공급하는 역할. value에 넣은 것이 하위 컴포넌트 전체에 공급된다
    <AdminContext.Provider value={{ isAdmin, setIsAdmin }}>
      {children}
    </AdminContext.Provider>
  )
}

export default AdminProvider

Day.2에서 배운 children이 여기서 쓰인다. AdminProvider는 감싸고 있는 내용(children)을 그대로 그리되, 그 주위에 Provider를 둘러서 값을 공급한다. 값과 setter를 객체 { isAdmin, setIsAdmin }로 묶어서 공급하면 받는 쪽에서 바꾸기도 할 수 있다.

(2) 앱 전체를 Provider로 감싸기

// main.jsx
createRoot(document.getElementById('root')).render(
  <AdminProvider>
    <App />          {/* 이 App이 AdminProvider의 children이다 */}
  </AdminProvider>
)
// 앱 전체에서 데이터를 공유해서 쓸 수 있게 설정한 것

(3) 필요한 곳에서 꺼내 쓰기

// App.jsx: state를 직접 만들지 않고 Context에서 가져온다
const { isAdmin, setIsAdmin } = useContext(AdminContext)
const onSwitch = () => { setIsAdmin(!isAdmin) }

return (
  <div>
    {isAdmin ? <p>관리자</p> : <p>관리자 아니다.</p>}
    <button onClick={onSwitch}>관리자 전원버튼</button>
    <Child1 />            {/* props를 안 넘겨도 된다 */}
  </div>
)
// Child1: 이제 isAdmin을 전달할 필요가 없어서 props가 없다. 자기 화면에 쓸 때만 꺼낸다
const Child1 = () => {
  const { isAdmin } = useContext(AdminContext)
  return (
    <div style={style}>
      <p>{isAdmin.toString()}</p>
      <Modibutton />
    </div>
  )
}

// Modibutton: 필요한 곳에서 바로 꺼낸다
const Modibutton = () => {
  const { isAdmin } = useContext(AdminContext)
  return <button disabled={!isAdmin}>관리자만 볼 수 있음</button>
}

실행해서 확인했다. 처음에는 관리자 아니다. / false / 버튼 비활성화(disabled=true)였고, 관리자 전원버튼을 누르면 관리자 / true / 버튼 활성화로 세 곳이 한꺼번에 바뀌었다. 중간의 Child1은 props를 하나도 안 받는데도 말이다.

propsContext
값 전달부모 → 자식 → 손자 순서로 한 단계씩Provider 아래라면 어디서든 바로
중간 컴포넌트쓰지 않아도 받아서 넘겨야 함신경 쓸 필요 없음
적합한 곳가까운 부모-자식 사이로그인 사용자, 테마처럼 앱 전체가 공유하는 값

Provider 쓰는 법 두 가지, 그리고 Provider 없이 쓰면

필기에는 공급 방식이 두 가지 나온다.

<AdminContext.Provider value={...}>   // 처음부터 썼던 방식
<Form1Context value={"student"}>      // .Provider 없이 Context 자체를 태그로

React 19 기준 둘 다 동작한다. 확인해 보니 둘 다 student를 정상 출력했다. 두 번째가 더 짧은 최신 방식이다.

createContext({})의 {}는 Provider가 없을 때 쓰이는 기본값이다. 이 기본값 때문에 한 가지 주의할 게 생긴다. 문자열을 공급하는 Context를 쓰면서 Provider로 감싸는 걸 깜빡하면, useContext가 {}를 돌려주고 {text}로 객체를 그리려다 에러가 난다. 실제로 이런 에러가 났다.

Objects are not valid as a React child (found: object with keys {})

기본값은 공급할 값과 같은 모양으로 맞춰 두는 것이 좋다(문자열이면 createContext('')).


3. 연습 문제들

이름 Context

Provider에서 이름 값을 제공하고, 다른 컴포넌트에서 그 이름을 화면에 출력한다.

export const nameContext = createContext({})

const Provider = ({ children }) => (
  <nameContext.Provider value={'테스트 이름'}>
    {children}
  </nameContext.Provider>
)

위 2번과 같은 구조다. 값이 객체가 아니라 문자열 하나여도 된다.

알림 버튼 + useCallback

const on = useCallback(() => { alert('안녕') }, [])
const name = useContext(nameContext)

return (<div><button onClick={on}>알림</button><br />{name}</div>)

Context와 useCallback을 같이 써 보는 문제였다. 다만 이 함수는 memo로 감싼 자식에게 넘기지 않고 일반 button에만 쓰기 때문에, useCallback을 쓰든 안 쓰든 차이가 없다. useCallback은 Day.6에서 정리한 것처럼 memo가 걸린 자식에게 함수를 넘길 때 의미가 있다.

증가 / 감소 + memo + useCallback

부모에서 증가/감소 버튼을 만들고, 자식은 카운트만 표시한다. 카운트 값이 바뀔 때만 자식을 렌더링한다. (React.memo + useCallback)

const [num, setNum] = useState(0)

const add = useCallback(() => {
  setNum((a) => a + 1)         // 함수형 업데이트라 num을 읽지 않는다 → 의존성 []
}, [])                         // num이 바뀌어도 함수를 새로 만들지 않는다

const sub = useCallback(() => { setNum(num - 1) }, [num])   // num을 읽으므로 [num] 필요
const equl = useCallback(() => { setNum(num) }, [num])      // 같은 값으로 설정

<Child1 number={num} setNum={setNum} />
const Child1 = memo(({ number, setNum }) => {
  console.log('memo 작동되는지 확인')
  return <div><p>{number}</p></div>
})

버튼을 눌렀을 때 Child1이 렌더링된 횟수를 세 봤다.

버튼Child1 렌더링
그대로(setNum(num))0번
증가1번
감소1번

그대로 버튼은 같은 값으로 state를 설정하는 것이라서 React가 "바뀐 게 없다"고 보고 App조차 다시 그리지 않는다. 그래서 Child1도 당연히 렌더링되지 않았다. 반대로 증가/감소는 숫자가 바뀌어 Child1이 렌더링된다. Child1의 props인 number가 바뀌었으니 memo가 있어도 렌더링하는 것이 맞다.

add처럼 함수형 업데이트를 쓰면 []로 고정할 수 있어서 가장 깔끔하다. sub/equl은 num을 읽기 때문에 [num]이 필요하고, 그러면 숫자가 바뀔 때마다 새 함수가 만들어진다. 여기서는 이 함수들을 Child1에 넘기지 않으니 문제는 없다. Child1에 넘기는 setNum은 React가 항상 같은 함수로 유지해 주는 값이라 memo를 깨지 않는다. 그리고 Child1은 setNum을 받기만 하고 쓰지 않는다.

로그인 Context

// App.jsx: Provider 역할까지 겸한다
export const loginContext = createContext({})
const App = ({ children }) => {
  const [user, setUser] = useState('')
  return (
    <div>
      <button onClick={() => setUser('user')}>login</button>
      <loginContext.Provider value={user}>{children}</loginContext.Provider>
    </div>
  )
}
// main.jsx
<App><Name /></App>
// Name.jsx
const user = useContext(loginContext)
return <div>{user}</div>

App이 state와 Provider를 같이 갖고 main.jsx에서 <App><Name /></App>처럼 자식을 넘기는 구조다. login 버튼을 누르면 Name에 사용자 이름이 표시되었다. 별도 Provider 파일 없이 이미 있는 컴포넌트가 Provider 역할을 겸할 수 있다는 점을 확인했다.

한 가지 참고할 점은 value에 {{ isAdmin, setIsAdmin }}처럼 객체 리터럴을 쓰면 Provider가 렌더링될 때마다 새 객체가 만들어져서, 그 값을 쓰는 컴포넌트가 매번 같이 다시 그려진다는 것이다. 규모가 커지면 useMemo로 감싸는 경우가 있다.

숫자가 10보다 큰지

const [num, setNum] = useState('')

<input value={num} onChange={(e) => setNum(e.target.value)} />
{num !== '' ? (Number(num) > 10 ? <p>10보다 큽니다.</p> : <p>10보다 작거나 같습니다.</p>) : ''}

중첩 삼항 연산자로 "빈칸이면 아무것도 안 보이고, 아니면 10과 비교한다"를 처리했다. 입력값은 항상 문자열이라서 Number(num)으로 바꿔서 비교했다. 여러 값을 넣어 봤다.

입력결과
1110보다 큽니다.
1010보다 작거나 같습니다.
-510보다 작거나 같습니다.
abc10보다 작거나 같습니다. ← 틀림
공백 하나10보다 작거나 같습니다. ← 틀림

숫자가 아닌 abc는 Number('abc')가 NaN이고, NaN > 10은 false라서 "작거나 같다"는 엉뚱한 결과가 나온다. 공백은 Number(' ')이 0이 된다. 숫자만 받으려면 <input type="number">를 쓰거나, Number.isNaN(Number(num))을 먼저 검사한다.

다크모드 / 라이트모드 토글

context로 dark(변수)와 toggleTheme(함수)를 자식에게 전달한다. 버튼을 클릭할 때마다 버튼 이름을 다크모드/라이트모드로 바꾼다.

const [dark, setDark] = useState('false')       // ← 문제
const toggleTheme = () => { setDark(!dark) }
<modeContext.Provider value={{ toggleTheme, dark, setDark }}>{children}</modeContext.Provider>
<button onClick={toggleTheme}>{!dark ? '다크모드' : '라이트모드'}</button>

useState('false')는 불리언 false가 아니라 문자열 'false'다. 빈 문자열이 아닌 문자열은 전부 truthy(참으로 취급)라서 !dark가 false가 된다. 실행해 보니 처음 버튼 글자가 라이트모드로 시작했고, 눌러도 다크모드 → 라이트모드 → ...로 반대 순서로 번갈아 바뀌었다. 첫 클릭부터는 !'false'가 진짜 불리언이 되어 정상적으로 토글된다. 초기값이 문자열 때문에 처음 한 번만 어긋난 것이다. useState(false)로 고치면 다크모드 → 라이트모드 → 다크모드로 의도대로 바뀐다.

참고로 이 문제는 버튼 이름만 바꾸고 실제 화면 색은 바꾸지 않았다. dark 값으로 클래스 이름이나 스타일을 바꾸면 완성된다.

동물 목록 추가/삭제

const [animals, setAnimals] = useState([
  { id: 1, name: '강아지' }, { id: 2, name: '고양이' },
  { id: 3, name: '토끼' },   { id: 4, name: '햄스터' },
])
const [newAni, setNewAni] = useState({ id: '', name: '' })

const onDelete = (id) => { setAnimals(animals.filter((i) => i.id !== id)) }
const onAdd = () => {
  setAnimals([...animals, { id: Date.now(), name: newAni.name }])
  setNewAni({ id: '', name: '' })
}

Day.4에서 배운 추가(스프레드)와 삭제(filter) 그대로다. 새 항목의 id를 Date.now()로 만들어서 삭제 후에도 id가 겹치지 않는다. 입력이 비어 있어도 빈 항목이 추가되는 점은 Day.4에서 정리한 trim() 검사로 막으면 된다. newAni의 id 칸은 쓰지 않는 값이라 name만 담는 문자열 state로 줄여도 된다.


4. localStorage: 새로고침해도 입력값 남기기

useState의 값은 새로고침하면 사라진다. 브라우저의 localStorage에 저장해 두고 다시 불러오면 값이 남는다.

const [user, setUser] = useState({ name: '', email: '', password: '' })

// 1) 마운트 시점에 localStorage에서 불러온다
useEffect(() => {
  const saveUser = JSON.parse(localStorage.getItem('user'))
  if (saveUser) { setUser(saveUser) }
}, [])

// 2) user가 바뀔 때마다 저장한다
useEffect(() => {
  localStorage.setItem('user', JSON.stringify(user))   // 객체 → 문자열로 저장
}, [user])
  • localStorage에는 문자열만 저장된다. 객체는 JSON.stringify로 문자열로 바꿔 넣고, 꺼낼 때 JSON.parse로 되돌린다. (Python Day.7에서 한 json과 같은 개념이다.)
  • 저장된 게 없으면 getItem이 null을 돌려주고 JSON.parse(null)도 null이라서 if (saveUser)로 걸러 준다.
  • 필기에는 "불러오는 effect가 저장하는 effect보다 앞에 와야 한다. 뒤에 있으면 불러오기 전에 빈값을 저장해서 빈값으로 덮어쓴다"고 적혀 있다.

이 코드를 직접 돌려 봤다. 저장소에 {"name":"saved"}를 넣어 둔 상태에서 컴포넌트를 마운트했다.

환경화면저장소
일반 렌더링saved유지됨
StrictMode (개발 모드)빈칸빈값으로 덮어써짐

StrictMode에서는 데이터가 날아갔다. 필기의 main.jsx는 StrictMode를 import만 하고 안 감싸서 눈치채지 못했을 수 있는데, npm create vite로 만든 기본 프로젝트는 <StrictMode>로 감싸져 있어서 같은 코드가 개발 중에 데이터를 지운다. 원인은 이렇다.

  1. StrictMode는 effect를 실행 → 정리 → 다시 실행한다.
  2. 첫 번째 실행에서 불러오기(setUser(저장값))를 예약해 두지만, 같은 시점에 저장 effect가 아직 빈 state(user의 초기값)를 저장소에 써 버린다.
  3. 두 번째 실행의 불러오기는 방금 덮어쓴 빈값을 읽어서 state를 빈값으로 만든다.

effect 순서를 맞춰도 이 문제는 해결되지 않는다. 가장 안전한 방법은 처음 state를 만들 때 바로 읽어 오는 것이다. useState에 함수를 넘기면 처음 렌더링 때 한 번만 실행되는 초기값 계산 함수(지연 초기화)로 쓰인다.

const [user, setUser] = useState(
  () => JSON.parse(localStorage.getItem('user')) ?? { name: '', email: '', password: '' }
)

useEffect(() => {
  localStorage.setItem('user', JSON.stringify(user))
}, [user])

이렇게 바꾸면 불러오는 effect가 필요 없다. 같은 방식으로 StrictMode에서 돌려 보니 화면과 저장소 모두 saved가 유지되었다. (??는 왼쪽이 null/undefined일 때만 오른쪽 값을 쓰는 연산자다.)

참고로 예제의 저장 버튼은 alert('저장')만 띄운다. 저장은 user가 바뀔 때마다 위 effect가 자동으로 하고 있어서, 실제로는 버튼이 하는 일이 없다. 또 localStorage는 누구나 개발자 도구로 볼 수 있는 곳이라 비밀번호를 평문으로 저장하는 것은 연습에서만 하고, 실제 서비스에서는 하지 않는다.


5. react-router-dom: 화면 전환 맛보기

주소(URL)에 따라 다른 컴포넌트를 보여 주는 라이브러리다. 먼저 설치한다.

npm install react-router-dom
// App.jsx
import { BrowserRouter, Route, Routes } from 'react-router-dom'

const App = () => (
  <BrowserRouter>
    <div>
      <NaviBar />
      <Routes>
        {/* /home 경로로 들어가면 <Home />을 보여 준다 */}
        <Route path="/home" element={<Home />} />
      </Routes>
    </div>
  </BrowserRouter>
)
// NaviBar.jsx
import { Link } from 'react-router-dom'

<ul>
  <li><Link to="/home">홈</Link></li>
  <li><Link to="/ing">회원관리</Link></li>
</ul>
  • BrowserRouter: 라우팅 기능을 켜는 최상위 감싸개.
  • Routes / Route: "이 주소(path)면 이 컴포넌트(element)" 라는 규칙표.
  • Link to="...": <a>처럼 생겼지만 페이지를 새로고침하지 않고 주소만 바꿔서 해당 컴포넌트로 전환한다.

테스트 환경에서는 BrowserRouter 대신 같은 역할을 하는 MemoryRouter로 확인했다.

  • /home으로 들어가면 Home이 보이고, Link는 <a href="/home">으로 그려졌다.
  • 필기의 NaviBar에는 /ing 링크가 있는데 /ing에 해당하는 Route가 없다. 이 주소로 들어가면 화면에는 아무것도 안 나오고 No routes matched location "/ing" 경고가 콘솔에 뜬다. 링크를 만들면 Route도 같이 만들어야 한다.
  • 세 번째 항목처럼 <Link>에 to를 빼면 에러 없이 href="/"(첫 화면)로 연결되었다. 실수하기 쉬운 부분이다.

useParams 훑어보기

다음에 할 일로 적어 둔 useParams도 같이 봤다. 주소의 일부를 변수처럼 받는 기능이다. 경로에 :이름을 쓰면 그 자리의 값이 변수가 된다.

<Route path="/user/:id" element={<User />} />

const User = () => {
  const params = useParams()      // /user/5 로 들어오면 { id: '5' }
  return <h2>{params.id}</h2>
}

/user/5로 들어갔더니 { "id": "5" }가 나왔고, 값은 문자열('5')이었다. 숫자로 쓰려면 Number(params.id)로 바꿔야 한다. 회원 목록에서 한 명을 눌러 /user/3 같은 주소로 이동해 상세를 보여 주는 식으로 쓰인다.


마무리

  • props를 중간 컴포넌트를 거쳐 계속 내려보내는 것을 props drilling이라고 하고, Context로 해결한다. createContext → Provider로 감싸기 → useContext로 꺼내 쓰기 순서다.
  • Provider가 없으면 createContext의 기본값이 쓰인다. 기본값은 공급할 값과 같은 모양으로 둔다.
  • 같은 값으로 set하면 React는 다시 그리지 않는다. useCallback은 memo가 걸린 자식에게 함수를 넘길 때만 의미가 있다.
  • useState('false')처럼 문자열 'false'는 truthy다. 입력값은 항상 문자열이므로 Number(...) 변환과 NaN 처리를 잊지 않는다.
  • localStorage는 문자열만 저장되고(JSON.stringify/parse), 불러오기는 effect가 아니라 useState의 초기값 함수로 하면 StrictMode에서도 데이터가 안 날아간다.
  • react-router-dom은 BrowserRouter > Routes > Route로 규칙을 만들고, 이동은 Link로 한다. 링크를 만들면 Route도 같이 만들고, 주소의 일부는 :id와 useParams로 받는다(값은 문자열).

Tags: React Context useContext localStorage react-router useParams useCallback 프론트엔드 개발자 학습기록

profile
난 뭘까

0개의 댓글