리액트 프레임워크가 제공하는 훅(hook)의 개념과 사용법

리액트에서는 컴포넌트의 생명주기(생성, 수정)를 제어할 수 있는 여러 가지 hook을 제공한다.
리액트와 리액트 네이티브의 컴포넌트의 속성은 불변이다. 한 번 렌더링된 컴포넌트의 상태는 변경할 수 없다. 컴포넌트의 데이터(상태)를 바꾸고 싶다면 hook을 사용해야 한다.
hook은 Spring에서의 bean을 등록하고 사용하는 것과 유사하다. 함수 또는 값을 리액트 캐시에 저장해놓고 필요할 때마다 사용하는 매커니즘이다.

useState

컴포넌트의 상태 변수와 상태를 업데이트 하는 콜백함수를 정의한다.

import React, {useState} from 'react'

const [상태_변수, set함수] = useState<상태의 타입>(초기값);

이렇게 획득한 set함수는 아래와 같이 사용하면 된다.

import {useState} from 'react'

const [avatars, setAvatars] = useState<Avatar>([]);

// 1. 새로운 상태를 직접 설정하기
setAvatars([...avatars, {/* 새로운 Avatar 객체 */}]);

// 2. 이전 상태를 기반으로 새로운 상태 설정
setAvatars((prevAvatars) => [...prevAvatars, {/* 새로운 Avatar 객체 */}]);

// 3. 초기화
setAvatars([]);

Closure와 Capture

클로저(closure)란 함수와 해당 함수가 선언된 환경(lexical environment)의 조합이다.

function createCounter() {
    let count = 0;
    return function() {
        return count++;
    };
}

const counter = createCounter();

console.log(counter()); // 0
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

useEffect

의존성 목록의 조건 중 어느 하나라도 만족하면 그때마다 콜백함수를 실행시킨다.

import React, {useEffect} from 'react'

useEffect(콜백, 의존성_목록)
콜백 = () => {}

의존성

리액트 프레임워크에서 의존성이란 캐시를 업데이트해야 하는 특정 상황을 의미한다. 리액트 프레임워크는 의존성 목록(배열) 중 하나라도 조건이 만족되면 캐시를 업데이트하고 해당 컴포넌트를 다시 랜더링하여 업데이트 내용을 화면에 반영한다.

한 번 저장된 캐시를 반환할 필요가 없다면 의존성으로 빈 배열을 전달하면 된다.

잘못된 의존성

의존성을 지정하지 않으면 컴포넌트의 상태가 바뀌어도 화면에 반영되지 않는다. 여기에서 상태useState에 의해 관리되는 대상이다.

아래의 코드는 start값이 false에서 true로 변해도 화면에 반영되지 않는다.(재랜더링 되지 않는다.)

export default function Timer(): ReactElement {
  const [avatars, setAvatars] = useState<IdandAvatar>([]);
  const [start, setStart] = useState(false);
  const toggleStart = useCallback(() => setStart(start => !start), []);
  const clearAvatars = useCallback(() => setAvatars(_ => []), []);
  let intervalId: NodeJS.Timeout;
  const addAvatar = () => {
    intervalId = setInterval(() => {
      if (start)
        setAvatars(avatars => [
          ...avatars,
          {id: D.randomId(), avatar: D.randomAvatarUrl()},
        ]);
    }, 1000);
  };
  useEffect(() => {
    addAvatar();
    return () => clearInterval(intervalId);
  }, []);  // useEffect에 전달된 콜백의 의존성의 정의되지 않았다.

  // 이하 생략
}

useEffect에 전달된 콜백의 의존성이 정의되지 않았기 때문start값이 바뀌어도 다시 랜더링되지 않는다.

Re-rendering trigger

모든 hook이 컴포넌트의 재랜더링을 trigger하지는 않는다. useEffect는 직접적으로 컴포넌트의 렌더링을 트리거하지 않는다. 다만 useEffect의 콜백 내부에서 useState가 관리하는 상태를 변경하는 함수(상태 변경함수)를 호출하는 경우 컴포넌트의 재랜더링을 트리거한다.
그렇기 때문에 useEffect의 의존성이 상태가 아니라 그냥 로컬 변수인 경우, 값이 변경되도 랜더링 되지 않는다.(화면에 반영되지 않는다.)

useMemouseCallback 훅은 각각 값이나 콜백을 캐싱(memoization)한다. 의존성 배열에 명시된 값에 변화가 있을 때에만 새로운 값이나 콜백을 생성한다. 의존성이 객체일 경우 deep copy와 shallow copy에 주의해야 한다.

useMemo

useMemo useCallback useEffect 모두 의존성 배열을 인자로 받으며 의존성에 변화가 발생할 때마다 콜백을 호출하여 의존성을 반영한다. 그리고 해당 컴포넌트는 다시 랜더링된다.

import React, {useMemo} from 'react'

const 캐시_데이터 = useMemo(콜백, [의존성1, 의존성2, ...])
콜백 = () => 원본_데이터

useMemo의 시그니쳐는 다음과 같다. useMemo캐시에 저장할 데이터를 반환하는 함수와 의존성을 인자로 받는다.

useMemo<T>(() => T, [의존성1, 의존성2, ...]): T

아래의 Memo 컴포넌트는 처음 렌더링될 때 D.makeArray(5).map(D.createRandomPerson)으로 생성한 데이터를 리액트 프레임워크가 관리하는 캐시에 저장한다.

import React, {ReactElement, useMemo} from 'react';
import * as D from '../data';

export default function Memo(): ReactElement {
  const people = useMemo(() => D.makeArray(5).map(D.createRandomPerson), [])
  return <></>;
}

useCallback

콜백 함수를 캐시한 뒤 필요할 때마다 꺼내서 사용하여 매번 콜백함수가 생성되는 것을 막아 성능을 향상시킨다.

const 캐시된_함수 = useCallback(캐시할_콜백, 의존성_목록)

useEffect vs useLayoutEffect

useEffect - asynchronous: 콜백함수의 실행이 완료될때까지 DOM painting을 기다리지 않는다.

useLayoutEffect - synchronous: 콜백함수의 실행이 완료될때까지 기다린 뒤 DOM을 화면에 출력(paint)한다.

리액트 공식문서에서는 useEffect 사용을 권장한다.

실습

아래의 코드들은 모두 결과가 똑같다. start를 터치하면 1초마다 랜덤으로 생성된 아바타가 추가된다.

addAvatar를 캐싱하지 않음

addAvatar 함수를 useCallback hook을 통해 캐싱하지 않았기 때문에 컴포넌트가 다시 랜더링 될때마다 addAvatar함수가 다시 정의된다.

import React, {ReactElement, useState, useCallback, useEffect} from 'react';
import {StyleSheet, View, Text, ScrollView} from 'react-native';
import {MD2Colors} from 'react-native-paper';
import {Avatar} from '../components';
import * as D from '../data';

const title = 'Timer';
type IdandAvatar = Pick<D.Person, 'id' | 'avatar'>;

export default function Timer(): ReactElement {
  const [avatars, setAvatars] = useState<IdandAvatar>([]);
  const [start, setStart] = useState(false);
  const toggleStart = useCallback(() => setStart(start => !start), []);
  const clearAvatars = useCallback(() => setAvatars(_ => []), []);
  let intervalId: NodeJS.Timeout;
  const addAvatar = () => {
    intervalId = setInterval(() => {
      if (start)
        setAvatars(avatars => [
          ...avatars,
          {id: D.randomId(), avatar: D.randomAvatarUrl()},
        ]);
    }, 1000);
  };
  useEffect(() => {
    addAvatar();
    return () => clearInterval(intervalId);
  }, [start]);
  const children = avatars.map(({id, avatar}) => (
    <Avatar
      key={id}
      uri={avatar}
      size={70}
      viewStyle={styles.avatarViewStyle}
    />
  ));
  return (
    <View style={[styles.view]}>
      <View style={styles.topBar}>
        <Text onPress={toggleStart} style={styles.topBarText}>
          {start ? 'stop' : 'start'}
        </Text>
        <Text onPress={clearAvatars} style={styles.topBarText}>
          clear avatars
        </Text>
      </View>
      <Text style={styles.title}>{title}</Text>
      <ScrollView contentContainerStyle={styles.contentContainerStyle}>
        {children}
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  view: {
    flex: 1,
    padding: 5,
    backgroundColor: MD2Colors.lime300,
    alignItems: 'center',
  },
  title: {fontSize: 30, fontWeight: '600'},
  topBar: {
    width: '100%',
    flexDirection: 'row',
    padding: 5,
    justifyContent: 'space-between',
    backgroundColor: MD2Colors.blue900,
  },
  topBarText: {fontSize: 20, color: 'white'},
  contentContainerStyle: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    justifyContent: 'center',
  },
  avatarViewStyle: {padding: 5},
});

addAvatar 함수를 캐싱

addAvatar함수를 캐싱했기 때문에 컴포넌트가 다시 렌더링되어도 함수를 재정의 하지 않고 저장된 함수를 불러와 사용한다.

export default function Timer(): ReactElement {
  const [avatars, setAvatars] = useState<IdandAvatar>([]);
  const [start, setStart] = useState(false);
  let intervalId: NodeJS.Timeout;
  const addAvatar = useCallback(() => {
    intervalId = setInterval(() => {
      if (start)
        setAvatars(avatars => [
          ...avatars,
          {id: D.randomId(), avatar: D.randomAvatarUrl()},
        ]);
    }, 1000);
  }, [start]);
  const toggleStart = useCallback(() => setStart(start => !start), []);
  const clearAvatars = useCallback(() => setAvatars(_ => []), []);
  useEffect(() => {
    addAvatar();
    return () => clearInterval(intervalId);
  }, [start]);
  const children = avatars.map(({id, avatar}) => (
    <Avatar
      key={id}
      uri={avatar}
      size={70}
      viewStyle={styles.avatarViewStyle}
    />
  ));
  return (
    <View style={[styles.view]}>
      <View style={styles.topBar}>
        <Text onPress={toggleStart} style={styles.topBarText}>
          {start ? 'stop' : 'start'}
        </Text>
        <Text onPress={clearAvatars} style={styles.topBarText}>
          clear avatars
        </Text>
      </View>
      <Text style={styles.title}>{title}</Text>
      <ScrollView contentContainerStyle={styles.contentContainerStyle}>
        {children}
      </ScrollView>
    </View>
  );
}

결과

timer-practice

reference

Comments