Jasontreks Blog

DM 보내기


Send

React 훅

React는 훅(Hooks)이라는 기능 집합을 제공한다. 아들을 사용하면 컴포넌트의 상태를 관리하거나 클릭에 대한 동적인 반응, 데이터 업데이트와 같은 작업이 가능해진다. 즉 동적인 웹 구성요소로 거듭나게 하는 매우 중요한 기능인것이다.

I. useState

useState 훅은 상태 변수와 변수의 setter 함수 및 초기화를 한줄로 빠르게 구성하는 기능이다.

import { useState } from 'react' // import 해야한다.

export const LoggedIn = () => {
    // useState 훅
    // const [상태 변수, setter 함수] = useState(초기값)
    const [isLoggedIn, setIsLoggedIn] = useState(false);

    // 핸들러를 정의해 setter 함수를 호출하도록 한다.
    const handleLogin = () => { setIsLoggedIn(true) }
    const handleLogout = () => { setIsLoggedIn(false) }
    return (
        <div>
            <button onClick={handleLogin}>Login</button>
            <button onClick={handleLogout}>Logout</button>
            <div>User is {isLoggedIn ? 'logged in' : 'logged out'}</div>
        </div>
    )
}

이 훅을 사용하면 isLoggedIn 값의 변화에 따라 다른 텍스트를 렌더링하는 등의 동적인 작업을 간편하게 구현할 수 있다.

const [isLoggedIn, setIsLoggedIn] = useState(false);

이 부분을 유심히 볼 필요가 있는데, 상태 변수인 isLoggedIn에 타입을 전혀 지정하지 않고 있다. 이것은 타입스트립트 규칙에 위배되는것처럼 보이지만, 오류는 없다. 왜냐하면 useState()안에 할당한 false 값을 보고 자동으로 isLoggedIn 이 boolean 타입이라고 추론하기 때문이다.

그래서 타입 규칙은 정확히 작동한다. setIsLoggedIn() 호출시 문자열이나 숫자를 넣으려고 하면 오류가 발생한다.

타입 명시

이런 상황을 생각해보자. 로그인 버튼을 누르면 이름, 이메일에 입력한 정보가 채워지지만 그 전에는 값 자체가 없어야한다. 또한 로그아웃을 누르면 값이 모두 사라져야 한다. 즉 null이 필요한 경우이다.

useState()로 이 경우를 관리한다면 타입 추론에 의존해서는 안된다. 초기화 시 당연히 useState(null)일텐데, 이러면 상태 변수는 null 타입으로 추론해버린다.

import { useState } from 'react'

type AuthUser = {
    name: string;
    email: string;
}

// user는 null타입이 된다.
// 즉 setUser로 AuthUser 타입의 객체를 넣으려 하면 오류가 발생한다.
const [user, setUser] = useState(null); 

그래서 user가 "AuthUser일 수도, null 일 수도 있음"을 컴파일러에 전달하기 위해 꺽쇄 기호 <>를 사용하여 타입을 명시한다.

export const User = () => {
    // 타입 명시. AuthUser 또는 null
    const [user, setUser] = useState<AuthUser | null>(null);

    // AuthUser 타입 맞는 객체를 전달해도 문제가 없다.
    const handleLogin = () => { setUser({
        name: 'jason',
        email: 'hello@jsn.com'
    }) }

    // null을 전달해도 문제가 없다.
    const handleLogout = () => { setUser(null); }

    return (
        <div>
            <button onClick={handleLogin}>Login</button>
            <button onClick={handleLogout}>Logout</button>
            // ?기호는 'null'이 아닐때만 속성에 접근하겠다는 의미이다.
            <div>User name is {user?.name}</div>
            <div>User email is {user?.email}</div>
        </div>
    )
}

타입 주장

위와 같은 상황에서, 초기값을 항상 null로 주게되면 해당 상태 변수를 사용할땐 이게 null일수도 있음을 염두에 두어야 하고, null일 경우에 대비한 코드도 짜야 한다. 하지만 프로그램을 만들다 보면 구조적으로 null일수가 없는 상황이 있다. 로그인의 경우 ID나 비번 입력이 없으면 벼튼 자체를 비활성화 할수도 있다. 이처럼 상태 전환이 일어났을 때 null이 아닐 것임이 기약된 상황에서, 타입 주장을 통해 상태 변수가 null타입일 가능성을 없애버릴 수 있다. 즉 타입스크립트에게 "이 상태 변수는 null인적이 없어. 처음부터 AuthUser 타입이야!" 라고 거짓말을 하는 것이다.

import { useState } from 'react'

type AuthUser = {
    name: string;
    email: string;
}

export const User = () => {
    // 타입 주장. 빈 객체를 만들고 as 키워드를 통해 AuthUser 타입일것임을 주장하는것이다.
    const [user, setUser] = useState<AuthUser>({} as AuthUser);
    const handleLogin = () => { setUser({
        name: 'jason',
        email: 'hello@jsn.com',
    }) }

    return (
        <div>
            <button onClick={handleLogin}>Login</button>
            // 따라서 ? 없이 null일 가능성을 염두에 두지 않고 코딩이 가능하다.
            <div>User name is {user.name}</div>
            <div>User email is {user.email}</div>
        </div>
    )
}

II. useReducer

useState 훅은 간단한 상태 값을 다루기에 좋다. 하지만 다음 상태가 이전 상태에 따라 달라지는 복잡한 로직의 경우 useReducer를 사용하는것이 좋다.

수치가 누적되는 상태 변수가 필요하다면, setter 함수는 단순히 상태변수에 새 값을 쓰는게 아니라 이전 상태변수를 가져와 연산을 하는것이 좋을것이다. 따라서 다음과 같이

export const Counter = () => {
    // setter함수 dispatch를 reducer하는 새로운 함수로 정의
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            <button onClick={() => dispatch({ type: 'increment', payload: 10 })}>
                Increment 10
            </button>
            <button onClick={() => dispatch({ type: 'decrement', payload: 10 })}>
                Decrement 10
            </button>
        </>
    )
} 

useReducer 훅으로 setter 함수가 호출할 다른 함수를 새로 정의하고 원하는 로직을 짤 수 있다. 이 때 새로운 함수는 첫번째 인자로 이전의 상태 변수를 받는다.

import { useReducer } from "react";

type CounterState = {
    count: number
}

type CounterAction = {
    type: 'increment' | 'increment'
    payload: number
}

const initialState = { count: 0 };

// setter가 호출하는 또다른 함수.
function reducer(state: CounterState, action: CounterAction) {
    switch (action.type) {
        case 'increment':
            return { count: state.count + action.payload };
        case 'decrement':
            return { count: state.count - action.payload };
        default:
            return state;
    }
}

export const Counter = () => {
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
        <>
            Count: {state.count}
            // setter 함수 호출. 이때 전달하는 인수는 두번째부터(첫번째는 state)
            <button onClick={() => dispatch({ type: 'increment', payload: 10 })}>
                Increment 10
            </button>
            <button onClick={() => dispatch({ type: 'decrement', payload: 10 })}>
                Decrement 10
            </button>
        </>
    )
} 

매개변수가 조금 복잡하게 보일 수 있어 그림으로 표현하자면 다음과 같다.


flowchart
useReducer -->|state| dispatch
onClick -->|action| dispatch
dispatch -->|state, action| reducer

그리고 최종 호출자인 reducer에서는 action 객체의 type을 확인하고 payload만큼 뺴거나 더하는 로직이 이루어진다.

유니온 타입

이제 상태를 자유자재로 변화시킬 수 있는 useReducer훅이라는 유용한 기능을 알게 되었다. 근데 이 상태변수가 어떤 상황에는 초기값(0)으로 초기화될 필요가 있다. 그러한 action도 reducer에 전달해보고자 한다.

function reducer(state: CounterState, action: CounterAction) {
    switch (action.type) {
        case 'increment':
            return { count: state.count + action.payload };
        case 'decrement':
            return { count: state.count - action.payload };
        // state 초기화 옵션
        case 'reset':
            return initialState;
        default:
            return state;
    }
}

근데, 이 경우 action에는 payload라는 속성이 전혀 필요하지 않다. 그래서 dispatch를 호출할 땐 payload에 무의미한 값을 전달하거나 CounterAction 타입의 payload 속성을 ?로 선언하여 필수가 아님을 타입스크립트에 알려야한다.

근데 이 방식이 최선일까? 임시 방편으로 돌려막기하는 그런 코드는 의도가 명확하지 않으며 가독성을 떨어뜨리고 유지보수를 어렵게 한다.

따라서, reducer 함수가 받을 CounterAction 타입은 payload가 포함된 증감 action이거나, payload가 없는 초기화 action일수도 있다고 타입스크립트에게 알려야 한다. 즉 서로 다른 두 타입을 합쳐 "이거일수도 있고 저거일수도 있음"을 명시하는 것인데 이걸 유니온 타입이라고 한다.

type UpdateAction = {
    type: 'increment' | 'decrement'
    payload: number
}

type ResetAction = {
    type: 'reset'
}

type CounterAction = UpdateAction | ResetAction

유니온 타입이 된 CounterAction은 dispatch를 호출하는 시점에서, 인수에 payload 속성이 있어도 되고 없어도 된다. 있으면 UpdateAction일거라 추론하고, 없으면 ResetAction일거라 추론한다.


III. useContext

Context는 전역적인 데이터를 관리하기 위한 객체이다.

Context를 사용하는 이유

리액트는 여러 컴포넌트들이 쌓이고 쌓여 웹페이지를 완성하는 식으로 작동하는다. 그리고 props를 주고 받고 함으로써 동적으로 테마를 변경하거나 사용자의 정보를 관리한다. 그런데 props를 전달할 땐 한 단계를 넘어 전달할수는 없다. 그래서 여러 단계를 거쳐 매우 안쪽에 있는 자식까지 props를 전달하려면 중간에 껴있는 컴포넌트들이 받아서 또 전달하고를 반복해야 한다. 이것을 "Props Drilling" 이라고 한다. 컨텍스트는 이 Props Drilling을 해결하기 위해 사용한다.

컨텍스트 만들기

테마 정보를 공급하는 컨텍스트를 만들어 볼것이다. 웹페이지에 있는 다크모드 온/오프 기능처럼, 여러 컴포넌트에 한번에 적용되어야 하는 테마 스타일은 흔한 컨텍스트 활용 예시이다.

먼저 테마 정보를 담는 오브젝트를 간단하게 만든다. 컨텍스트가 제대로 작동한다면, 웹 페이지 사용자는 테마를 primary나 secondary로 자유롭게 바꿀 수 있을 것이다.

theme.ts
export const theme = {
    primary: {
        main: '#3f51b5',
        text: '#fff',
    },
    secondary: {
        main: '#f50057',
        text: '#fff'
    }
}

그리고 createContext를 불러와 컨텍스트를 만듦과 동시에 Provider도 정의한다. Provider 역시 하나의 리액트 컴포넌트지만 실제로 렌더링되는 요소는 아니다.

ThemeContext.tsx
import { createContext } from "react";
import { theme } from "./theme";

type ThemeContextProviderProps = {
    children: React.ReactNode // 필수 prop 
}

export const ThemeContext = createContext(theme)

export const ThemeContextProvider = ({children}: ThemeContextProviderProps) => {
    return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
}

props의 타입들 중에는 React.ReactNode가 꼭 있어야 한다. 컨텍스트를 사용할 컴포넌트를 받아야 하기 때문. 그리고 JSX 구문에서 반환하는 ThemeContext.Providervalue prop이 바로 우리가 정의한 테마를 전달하는 인자이다.

컨텍스트 사용하기

Box.tsx
import { useContext } from "react"
import { ThemeContext } from './ThemeContext'

export const Box = () => {
    const theme = useContext(ThemeContext)

    return <div style={{ backgroundColor: theme.secondary.main, color: theme.primary.text }} >Theme context</div>
}

컨텍스트를 사용하는 컴포넌트인 Box에서는 useContext와 우리가 만든 ThemeContext를 불러와 theme 값을 전달받아 스타일에 사용하게 된다.

컨텍스트로 받은 값 변경?

컨텍스트로 전달받은 value 객체는 기본적으로 읽기 전용(Read-Only) 객체이다. 따라서 값을 직접 변경하는것은 불가능하다. 대신 setter 함수를 만들어 함께 컨텍스트로 전달할 수 있다. 이 내용은 뒤에서 자세히 다루어본다.

컴포넌트 배치하기

컨텍스트를 사용할 컴포넌트들을 배치할 땐 아래와 같이 ContextProvider 컴포넌트의 자식으로 오게 하면 된다.

App.tsx
import './App.css';

import { ThemeContextProvider } from './components/context/ThemeContext';
import { Box } from './components/context/Box';

function App() {
  return (
    <div className="App">
      <ThemeContextProvider>
        <Box/>
      </ThemeContextProvider>
    </div>
  );
}

export default App;

컨텍스트 업데이트

테마같은 경우 웹페이지 제작자가 미리 정해놓고 고르게 하는 것이므로 업데이트가 필요하지 않다. 근데 로그인 정보를 생각해보자. 사용자가 로그인함으로서 업데이트되는 이름과 이메일 주소같은 정보는 테마처럼 미리 정해놓을 수 없다. 이처럼 어떤 데이터는 사용자의 입력등에 의해 업데이트가 필요하다. 컨텍스트로 이 업데이트를 수행하려면 컨텍스트에 setter 함수를 포함시켜 전달해야 한다.

그래서 이전처럼 createContext를 그냥 쓰면 안되고 컨텍스트 타입을 새롭게 정의해야 한다.

UserContext.tsx
import React, { useState, createContext } from "react"

export type AuthUser = {
    name: string
    email: string
}

type UserContextType = {
    user: AuthUser | null // 컨텍스트로 전달할 데이터
    setUser: React.Dispatch<React.SetStateAction<AuthUser | null>> // setter함수
}

type UserContextProps = {
    children: React.ReactNode
}

// 데이터 + setter가 결합된 타입으로 컨텍스트 생성
export const UserContext = createContext<UserContextType | null>(null);

export const UserContextProvider = ({ children }: UserContextProps) => {
    // Provider 컴포넌트 안에서 State 사용해 user 정보 관리
    // State의 setter인 setUser를 컨텍스트에 전달
    const [user, setUser] = useState<AuthUser | null>(null)
    return (
        <UserContext.Provider value={{user, setUser}}>
            { children }
        </UserContext.Provider>
    )
}

그래서 총 세 가지의 타입 정의가 필요하다.

구분용도
데이터 타입컨텍스트로 전달할 데이터의 타입
컨텍스트 타입컨텍스트 자체의 타입(데이터+setter)
Props 타입Provider가 받을 인자(자식 컴포넌트) 타입(ReactNode)

이제 해당 컨텍스트를 전달받은 컴포넌트에선 다음과 같이, setter로 함께 전달 setUser를 호출해 컨텍스트의 데이터를 업데이트 할 수 있게된다.

User.tsx
import { useContext } from "react"
import { UserContext } from "./UserContext"

export const User = () => {
    const user = useContext(UserContext);

    const handleLogin = () => { user?.setUser({name: "jason", email: "google"}) }
    const handleLogout = () => { user?.setUser(null) }

    return (
        <div>
            <button onClick={handleLogin}>Login</button>
            <button onClick={handleLogout}>Logout</button>
            <div>User name is {user?.user?.name} </div>
            <div>User email is {user?.user?.email} </div>
        </div>
    )
}

IV. useRef