React 가이드 6편 - Effects, Reducers & Context

kdeun1·2022년 11월 12일
1

React 정리

목록 보기
6/7
post-thumbnail

더 복잡한 리액트 앱을 만들기 위해 사용. (Side) Effects로 코딩하는 방식과, reducer를 통해 복잡한 state를 관리하는 방법, context를 통해 여러 컴포넌트 간에 state를 공유/업데이트를 쉽게 하는 방법을 알아본다.

Effects

리액트는 사용자 입력에 반응하여 상호작용하는 UI를 렌더링하는 역할을 가진다. JSX 코드와 DOM을 평가하고 렌더링하고, state와 props를 관리하고, 사용자 입력에 따라 컴포넌트 함수를 재평가하고 DOM을 리렌더링한다.
사이드 이펙트는 앱에서 일어나는 모든 것을 뜻한다. 예를 들어 http req를 보내거나 browser storage에 저장하거나 timer나 interval을 설정/관리하는 것들 등이 있다. 이 부분들은 화면의 UI에 직접적인 관계가 없다. 이러한 부분은 일반적인 컴포넌트 평가 밖에서 일어나는 일이다. 예를 들어 컴포넌트 함수 내 http req를 보내는 로직으로 인해 state가 재평가되어 다시 재실행되었을 때, 무한 루프에 빠질 수 있다. 그러므로 사이드 이펙트는 컴포넌트 함수 내에 직접적으로 들어가면 안된다. 버그나 무한 루프가 발생할 가능성이 높기 때문이다.

useEffect()

useEffect()라는 리액트 내장 훅을 사용하여 이런 사이드이펙트의 문제를 처리해야한다.

useEffect(() => { ... }, [ dependencies ]);

2개의 전달인자가 존재한다.

  • 첫 번째 전달인자 : 특정 의존성이 변경된 경우에 모든 컴포넌트가 평가된 후 실행되는 콜백 함수이다.
  • 두 번째 전달인자 : 특정 의존성으로 구성된 배열. 이 의존성이 변경될 때마다 첫 번째 콜백 함수가 실행된다.
    컴포넌트가 리렌더링될 때 실행되지 않는다. 의존성이 변경된 경우에만 실행시킬 수 있다.

useEffect() 사용 예제

페이지를 새로고침하더라도 로그인 상태를 유지하고 싶다. 로그인 정보를 state에 저장한 경우에는 불가능하다. 본질적으로 앱을 리로드할 때, 전체 리액트 스크립트가 다시 시작되며, 리액트 앱에 있는 변수 데이터들은 사라진다. 앱이 시작될 때 데이터가 유지되는지 확인해본 뒤 데이터가 있는 경우 자동으로 로그인을 시킬 것이다. 이 때 useEffect()를 사용하면 로긴 정보를 다시 입력할 필요가 없다. 여러 스토리지가 있지만 그 중 localStorage에 로그인이 되었다는 정보를 K-Y로 저장해본다.
사용자가 페이지를 떠났다가 다시 돌아오거나, 새로고침 하는 경우 localStorage에 K-Y가 있는지 확인해본다. App() 안에 localStorage에 로그인 정보가 있는지 확인한 이후에 로그인 state 설정 함수를 호출하게 되면 컴포넌트 함수가 재실행되고 다시 로그인 state 설정 함수를 호출하는 식의 무한루프 에러가 발생한다.

Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

이 때, useEffect()를 사용한다. useEffect의 첫 번째 전달인자 콜백함수 내부에 localStorage에 로그인 정보가 있는지 확인한 이후에 로그인 state 설정 함수를 호출하는 로직을 넣는다. 콜백 함수의 코드는 모든 컴포넌트가 재평가된 이후에 실행된다.

useEffect()와 종속성

  1. 두 번째 인자가 빈 배열일 때는 컴포넌트가 최초 렌더링된 이후에 한 번만 실행할 때 사용한다. 빈 배열을 넣음으로써 종속성이 절대 변경되지 않기 때문에(없기 때문에) 앱이 시작될 때 1번만 실행된다.
useEffect(()=>{
  // 로직
}, []);
  1. 종속성이 없으면 컴포넌트가 리렌더링될 때마다 실행된다.
useEffect(()=>{
  // 로직
});
  1. 종속성 배열 안의 것들이 변경될 때마다 실행된다. 예를 들어 form에 유효성 검사(validate check)를 할 때는 앱에서 1번만 실행되는 것이 아니라 사용자가 입력되는 값이 변경함에 따라 해당 로직을 실행하여 valid처리를 할 수 있게 만든다.
useEffect(()=>{
  // 로직
}, [idState, pwState]);

콜백 함수의 로직을 실행시키기 위해 종속성에 추가할 수 있는 것은 state, props, state를 set하는 함수 등이 들어갈 수 있다. 하지만 state를 업데이트하는 함수는 절대 변경되지 않으므로 추가할 필요가 없다. useEffect()를 통해 관리하는 사이드이펙트 들은 http req나 timer가 있지만, 그 외에 키 입력을 통한 데이터 저장하여 유효성 검사를 하는 것(사용자 입력 데이터의 사이드 이펙트)도 포함된다. 어떠한 액션에 대한 응답으로 실행되는 액션도 사이드 이펙트이다. 이 때 useEffect()가 도움이 된다.

종속성으로 추가되는 항목들

import { useEffect, useState } from 'react';
 
let myTimer;
 
const MyComponent = (props) => {
  const [timerIsActive, setTimerIsActive] = useState(false);
 
  const { timerDuration } = props; // using destructuring to pull out specific props values
 
  useEffect(() => {
    if (!timerIsActive) {
      setTimerIsActive(true);
      myTimer = setTimeout(() => {
        setTimerIsActive(false);
      }, timerDuration);
    }
  }, [timerIsActive, timerDuration]);
};
  • 종속성 배열에 들어가지 않는 것들 : state를 업데이트 하는 함수, 내장 API, 내장 함수, 컴포넌트 외부에서 정의되는 변수, 함수
  • 종속성 배열에 들어가는 것들 : state인 timerIsActive 반응형 변수, props 값인 timerDuration 변수

useEffect()의 Cleanup 함수

키가 입력될 때마다 유저가 이미 사용 중인지 http req를 보내는 경우에는 불필요한 네트워크 트래픽을 만들게 된다. 유저가 키입력을 하는 동안에는 validate check를 하지 않고 타이핑이 멈출 때를 기다린다. 연속으로 호출되는 함수들 중에 마지막에 호출되는 함수(또는 제일 처음 함수)만 실행되도록 하는 Debouncing(그룹화) 효과를 준다.
useEffect()의 첫 번째 전달인자인 콜백 함수에서 함수(익명 화살표 함수나 함수명 등)를 반환할 수 있다. 이 함수를 cleanup 함수라고 한다. cleanup 함수는 새로운 사이드이펙트 함수가 실행되기 전과 컴포넌트가 제거되기 전에 실행된다. 첫 번째 사이드이펙트 함수가 실행되기 전에는 cleanup 함수가 실행되지 않는다.

  useEffect(() => {
    // debouncing valid logic
    const identifier = setTimeout(() => {
      console.log('form의 유효성 검사 중');
      setFormIsValid(
        enteredEmail.includes('@') && enteredPassword.trim().length > 6
      );
    }, 1000);

    // cleanup 함수
    return () => {
      console.log('cleanup 함수!');
      clearTimeout(identifier);
    };
  }, [enteredEmail, enteredPassword]);

정리

useEffect()는 사이드이펙트를 다루는 중요한 리액트 훅이다. useEffect()는 컴포넌트가 처음 마운트된 이후와 렌더링 사이클 이후에 실행된다.
의존성이 없는 useEffect()는 거의 사용되지 않는다. state가 변경될 때마다 컴포넌트 함수가 재실행되면서 매 번 의존성이 없는 useEffect()가 실행되기 때문이다.
의존성이 빈 배열만 있는 useEffect()는 컴포넌트가 최초로 마운트되고 렌더링될 때 단 1번만 실행된다. useEffect()는 컴포넌트가 처음 마운트된 이후에 실행되고 나서 그 뒤로 변경될 수 없기 때문에 once인 것이다.
의존성 배열에 의존 값이 있는 경우, 의존 값이 처음 생성되었을 때와 변경되었을 때 이펙트 함수가 실행된다.
그리고 클린업 함수는 useEffect()의 첫 번째 전달인자의 콜백 함수에서 반환되는 함수인데, 컴포넌트가 삭제될 때 함수가 실행된다. unmount 훅의 타이밍이다.


Reducers

useReducer()은 복잡한 state 관리를 도와주는 훅이다. 여러 state들이 함께 속한 경우에 사용/관리적 측면에서 버그없이 효율적으로 사용할 수 있다. useState()보다 더 강력하게 state를 관리하는데 대체해서 사용할 수 있다(강력하다고 해서 더 좋다는 것은 아님). state가 더 복잡하고 커지고 여러가지 관련된 state가 결합된 경우라면 useReducer()도 고려해볼만 하다.

useReducer()

const reducerFn = (oldState, action) => {
  return oldState + LOGIC; // new state
};

const [state, dispatchFn] = useReducer(reducerFn, initialState, initFn);
  • state : 컴포넌트가 리렌더링/재평가 사이클의 최신 state 스냅샷이다.
  • dispatchFn : 최신 state 스냅샷을 업데이트하는 함수이다. useState의 setStateFn과 다르게 작동한다. 새로운 state 값을 설정하는 대신에 새로운 action을 dispatch한다.
  • reducerFn : 리듀서 함수는 최신 state 스냅샷을 자동으로 가져오는 함수이며, dispatchFn()를 통해 dispatch된 action을 가져온다. 리액트는 새로운 actions이 dispatch될 때마다 reducerFn()을 호출하기 때문이다. 새롭게 변경된 state를 반환한다. (useState + action)
  • initialState : 초기 state
  • initFn : 초기 state를 설정하기 위해 실행하는 함수

javascript의 내장함수 .reduce()랑 비슷한 로직으로 생각하니 이해하기 쉬웠다.

적용

로그인 시 이메일 폼의 유효성을 체크하는 코드를 useReducer()를 사용하여 여러 useState()를 합칠 수 있다.
reducerFn은 컴포넌트 함수 외부에서 선언할 수 있다. 컴포넌트 함수 내부 스코프와의 상호작용이 없기 때문이다.
useReducer는 한 컴포넌트 내에서 State를 업데이트하는 로직 부분을 그 컴포넌트로부터 분리시키는 것을 가능하게 해준다는 것이다.

useState() vs. useReducer()

useState()를 사용하면서 너무 번거롭거나 복잡한 state의 경우, 관련 state 스냅샷들이 서로 독립적인 상태에서 각자 업데이트가 잘 안되면 useReducer()를 사용한다.

  • useState()는 메인 state 관리도구이다. 개별 state/data를 다루기에 적합하다. state를 변경하기 쉽다.
  • useReducer()는 state가 객체 형태이거나 reducerFn을 사용해서 복잡한 state 변경 로직을 포함시킨다. 연관되어있거나 복잡한 state/data를 다루거나 변경하는 경우에 도움이 된다.

Context

props를 통해 많은 컴포넌트를 거쳐 많은 데이터를 전달할 때 생기는 문제를 해결할 수 있다. 데모 코드에서는 로그인 정보가 App.js 파일에 있다. 로그인 정보는 App컴포넌트에서 헤더 및 하위 컴포넌트까지 props로 전달되고 있는 상황이 존재한다. 문제점은 앱이 커질수록 props가 여러 컴포넌트를 거쳐서 내려간다면 중간 컴포넌트들은 전달만 하는 단순한 패싱역할만 하고 전달경로가 길수록 점점 길어진다는 것이다. props 체인을 사용하지 않고 state를 관리할 수 있도록 도와준다. 리액트에 내장된 내부적인 state storage를 리액트 context라고 한다.

Context API

컴포넌트명은 PascalCase로 파일명을 지었지만, context를 저장하는 store라는 폴더 안에서는 컴포넌트 역할이 아니기 때문에 kebab-case로 명명한다. react의 createContext() 함수를 사용하여 컨텍스트 객체를 생성한다. createContext() 함수 안에 들어갈 것은 대부분 객체이다.
첫 번째로는 auth-context.js파일을 생성하고 context를 정의한다.

import { createContext } from 'react';

const AuthContext = createContext({
  isLoggedIn: false,
});

AuthContext는 컴포넌트가 아니지만 컴포넌트를 포함할 객체이므로 파스칼케이스로 명명하였다. context를 provide(제공, 공급)하고 consume(소비)하면서 사용할 것이다.

두 번째로는 provide한다. 이는 JSX 코드로 context로 컴포넌트를 감싸는 것을 뜻한다. 위에서 정의한 context를 App.js에서 전역으로 사용하기 위해 아래와 같이 Provider로 래핑한다.

const App = () => {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  
  return (
    <AuthContext.Provider
      value={{
        isLoggedIn: isLoggedIn,
      }}
    >
      <MainHeader />
      <main>
        // ...
      </main>
    </AuthContext.Provider>
  );
};

세 번째로는 이를 listening해본다. context에 정의한 값에 접근하기(갖고오기) 위해서는 리스닝을 해야하는데, context의 consume을 사용하거나 hook을 사용하여 리스닝할 수 있다. 일반적으로는 훅을 사용하지만 다른 대안으로 Consumer도 고려할만하다.

const Navi = props => {
  return (
    <AuthContext.Consumer>
      {ctx => {
        return (
          <nav>
           {ctx.isLoggedIn && (<ul><li>Users</li></ul>)}
          </nav>
        );
      }}
    </AuthContext.Consumer>
  );
};

useContext() 훅으로 Context에 tapping 하기

useContext()을 사용해보자. useContext()의 파라미터로 Context의 포인터를 전달한다. 위의 방식을 좀더 우아하게 바꿀 수 있다.

const { useContext } from 'react';
const AuthContext from '../store/auth-context';

const Navi = props => {
  const ctx = useContext(AuthContext);
  
  return (
    <nav>
      {ctx.isLoggedIn && (<ul><li>Users</li></ul>)}
    </nav>
  );
};

Dynamic Context

컴포넌트뿐만 아니라 함수에도 데이터를 전달할 수 있도록 동적으로 context를 설정할 수 있다.

<AuthContext.Provider
  value={{
    isLoggedIn: isLoggedIn,
    onLogout: logoutHandler,
  }}
>

위와 같이 Provider에 함수 핸들러 포인터를 연동한다. AuthContext를 리스닝하는 모든 컴포넌트들은 logoutHandler 함수를 활용할 수 있다(함수 포인터를 바인딩하였기 때문에). Consumer 컴포넌트에서는 더이상 props를 사용할 필요가 없다.
대부분의 경우 props를 사용하여 컴포넌트에 데이터를 전달한다. props는 컴포넌트를 구성하고 재사용할 수 있도록 하는 매커니즘이기 때문이다.
많은 컴포넌트를 통해 전달하고자 하는 것들이 있는 경우나, 매우 특정적인 일을 하는 컴포넌트에는 context를 사용하는 것이 좋다. context를 사용하면 더 간결한 코드를 작성할 수 있다.

custom context provider component

타입 정의, 사용함에 따라 context 파일에서 createContext()함수에 정의되는 초기 객체에는 속성 값들을 모두 적어준다. IDE의 자동완성에도 도움을 준다.
AuthContext를 관리하는 store > auth-context.js 파일에서 로그인에 관련된 전체 인증 state를 별도의 provider component도 관리할 수 있다. AuthContextProvider 컴포넌트에서 전체 로그인 state를 관리하며, 관련 AuthContext를 설정한다. 이처럼 하나의 context 파일에 인증관련 로직이 모두 존재한다면 App.js에 존재하는 인증 로직에 대한 관심사를 분리(App.js 파일에서 인증 로직을 제거)할 수 있다.
index.js 파일에서 <AuthContextProvider> 컴포넌트로 <App /> 태그를 감싸게 되면 하나의 중앙 저장소가 래핑하게 된다. 그러면 <App /> 컴포넌트가 더 간결해지고 인증 state 관리와 관련이 없게 된다.

제한 사항

context api는 앱 전체, 컴포넌트 전체 state에는 적합할 수 있지만 component configuration은 대체할 수 없다. 또한 state의 변경이 빈번한 경우(예를들어 초당 데이터가 여러 번 변경되는 등의 경우)에도 최적화되지 않아 적합하지 않다.

일반적으로 Vue나 React에서 props를 사용하여 데이터를 송수신한다. 이는 Presentational and Container Components 패턴이며, 과거에 사용되어왔으며, 2019년에 Dan Abramov라는 사람이 사용하지 말라고 언급하였다. Prop drilling의 단점을 극복하면서 여러 하위의 컴포넌트들에게 영향을 끼치는 패턴은 Provider 패턴이며, React의 Context나 Vue의 Provide/Inject에 해당한다. 개인적으로 위의 두 문법이 비슷한 개념이라고 생각한다.


Hooks의 규칙

useState, useEffect, useReducer, useContext 외에 훅들이 존재한다. 리액트 훅들은 use- 접두사가 붙는다.
1) 리액트 훅은 리액트 함수(리액트 컴포넌트 함수, custom hooks) 안에서만 호출되어야 한다. 요즘 좋은 IDE가 이런 부분에 대한 휴먼에러를 커버해준다.
2) 리액트 훅은 함수 컴포넌트나 커스텀 훅 함수의 최상위 레벨에서만 호출할 수 있다. 중첩함수나 block문에서는 호출하면 안된다. 예를 들어 useEffect() 훅 안에서는 useContext()를 호출할 수 없다는 뜻이다.
3) useEffect() 훅은 항상 참조하는 모든 항목을 의존성 배열의 값들이 useEffect()의 effectFn 콜백함수 내부에 존재해야한다. 이 부분도 IDE가 알려준다.

Input 컴포넌트 만들기

평범한 label, input 태그들의 row를 하나의 컴포넌트화시켜서 재사용성을 높이는 방법을 진행하였다.


참고

profile
프론트엔드 개발자입니다.

0개의 댓글