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

리액트에서 Ref는 어떤 값을 담거나 DOM 요소를 참조할 수 있는 객체이다.

HTML 요소 제어

Ref를 활용하는 대표적인 방법 중 하나는 HTML 요소에 대한 직접적인 제어 로직이다. 아래 컴포넌트는 Ref를 사용해 input 요소를 참조하여 저장한다. 해당 컴포넌트가 렌더링 된 이후 useEffect가 실행되면서, 해당 HTML 요소에 focus를 맞추게 된다.

DomRef.tsx
import { useRef, useEffect } from 'react'

export const DomRef = () => {
    const inputRef = useRef<HTMLInputElement>(null!);

    // 3. focus 실행
    useEffect(() => {
        inputRef.current.focus()
    }, [])

    // 1. 컴포넌트 렌더링
    return (
        <div>                // 2. DOM 요소 참조
            <input type="text" ref={inputRef}/> 
        </div>
    )
}
useEffect 훅

useEffect는 컴포넌트가 렌더링 된 이후의 보조적인 동작을 실행시키는 훅이다. JSX 이전의 코드는 렌더링되기 전이므로 DOM 요소에 접근할 수 없지만, useEffect 내에선 렌더링 이후의 실행이 보장되므로 DOM 요소에 접근해 보조적인 작업을 처리할 수 있다. 또 useEffect 함수의 반환으로 함수 객체를 정의하여, 페이지를 떠날 때의 작업을 구현할수도 있다. 값을 초기화하거나 타이머처럼 지속적으로 수행되는 동작을 중지하는 등의 작업이다. '클린 업' 로직이라고 한다.

자유도 높은 전용 저장소

두번째 활용 방법으로는 컴포넌트에서 지속적으로 사용할 어떤 데이터를 저장하고 관리하는 로직이다.

MutableRef.tsx
import { useState, useRef, useEffect } from 'react'

export const MutableRef = () => {
    const [timer, setTimer] = useState(0)
    // 타이머 객체의 ID를 담는 Ref
    // ID는 정수이므로 Ref의 타입을 number로 명시
    const interValRef = useRef<number | null>(null)
    // 여기서 Ref에 저장된 타이머 ID를 사용
    const stopTimer = () => {
        if (interValRef.current)
            window.clearInterval(interValRef.current)
    }
    
    useEffect(() => {
        // 1. 렌더링 직후 1초마다 1씩 증가하는 타이머를 생성
        // 그 생성자의 반환값은 ID이므로 Ref에 저장
        interValRef.current = window.setInterval(() => {
            setTimer((timer) => timer + 1)
        }, 1000);
        // 2. 클린업 함수 반환 -> 페이지 떠날 때 실행
        return () => {
            stopTimer();
        }
    }, []);

    return (
        <div>
            HookTimer - {timer} - 
            <button onClick={() => stopTimer()}>Stop Timer</button>
        </div>
    )
}

위 예제에서 Ref는 타이머 유틸리티 객체를 생성하면서 받은 타이머 ID를 저장하는데 쓰인다. Ref는 State와 달리 값의 변경이 일어나도 다시 렌더링되지 않는다. 리액트의 규칙으로부터 자유로운 것인데, 따라서 객체 ID같은 UI와 무관한 어떤 값을 저장하고 관리하는데 사용함으로서 불필요한 리렌더링을 줄인다.

State와 Ref의 차이

State는 리액트의 규칙을 따르는 객체로서, setter에 의해 값의 변경이 일어나고 값이 변경되면 컴포넌트 전체가 다시 렌더링된다. 따라서 State는 UI와 직접적인 연관이 있는 데이터를 저장하는데 쓰인다. Ref는 리액트 규칙으로부터 자유로워 렌더링을 발생시키지 않아 조용하고 효율적인 데이터 처리가 가능하다. 또 Ref는 메모리 주소의 불변성을 가진다. 힙 영역에 새로운 객체를 하나 만들고 current 속성으로 참조하는 원리이다. 따라서 일반 지역변수처럼 컴포넌트 호출시마다 초기화되지 않고 정적으로 데이터를 관리할 수 있다.