Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hook (useEffect) #7

Open
yjkwon07 opened this issue Oct 10, 2020 · 0 comments
Open

Hook (useEffect) #7

yjkwon07 opened this issue Oct 10, 2020 · 0 comments
Labels
Hook Hook에 대한 고찰

Comments

@yjkwon07
Copy link
Member

yjkwon07 commented Oct 10, 2020

Hook (useEffect)

모든 랜더링은 고유의 Props과 State가 있다

  • component를 호출하여 렌더링을 통한 리액트 요소에는 호출 시점에 가지고 있던 props, state가 명시되어 있다.

모든 랜더링은 고유의 이벤트 핸들러를 가진다

  • 비동기 함수안에 사용된 props, state는 호출 시점에 가지고 있던 props, state를 사용한다.
  • 클로저 개념

모든 랜더링은 고유의 이펙트를 가진다

  • 우리가 형성하고 있는 멘탈 모델 속에서 이펙트 함수는 이벤트 핸들러처럼 특정 랜더링에 속하는 함수라고 생각하면 된다.
리액트: state가 0 일 때의 UI를 보여줘.
컴포넌트:
  여기 랜더링 결과물로 <p>You clicked 0 times</p> 가 있어.
  그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마: () => { document.title = 'You clicked 0 times' }.
리액트: 좋아. UI를 업데이트 하겠어. 이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해.
브라우저: 좋아, 화면에 그려줄게.
리액트: 좋아 이제 컴포넌트 네가 준 이펙트를 실행할거야.
() => { document.title = 'You clicked 0 times' } 를 실행하는 중.
  • 상태를 바꾼 후의 리액트 - 컴포넌트 - 브라우저와의 대화이다.
컴포넌트: 이봐 리액트, 내 상태를 1 로 변경해줘.
리액트: 상태가 1 일때의 UI를 줘.
컴포넌트:
  여기 랜더링 결과물로 <p>You clicked 1 times</p> 가 있어.
  그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마: () => { document.title = 'You clicked 1 times' }.
리액트: 좋아. UI를 업데이트 하겠어. 이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해.
브라우저: 좋아, 화면에 그려줄게.
리액트: 좋아 이제 컴포넌트 네가 준 이펙트를 실행할거야.
() => { document.title = 'You clicked 1 times' } 를 실행하는 중.

모든 랜더링은 고유의… 모든 것을 가지고 있다

클래스 컴포넌트로 만들면 이렇게 동작하지 않았습니다. 이 링크의 클래스 컴포넌트도 함수 컴포넌트와 같은 동작을 하리라고 착각하기 쉽다.

componentDidUpdate() {
  setTimeout(() => {
     console.log(`You clicked ${this.state.count} times`);
  }, 3000);
}
  • this.state.count 값은 특정 랜더링 시점의 값이 아니라 언제나 최신의 값을 가리킵니다. 그래서 매번 최종으로 업데이트가 되어있는 count로 찍혀있는 로그를 보게 됩니다.

흐름을 거슬러 올라가기

  • 때때로 이펙트 안에 정의해둔 콜백에서 사전에 잡아둔 값을 쓰는 것이 아니라 최신의 값을 이용하고 싶을 때가 있다.
    • 제일 쉬운 방법은 ref를 이용
function Example() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);
  useEffect(() => {
    // 변경 가능한 값을 최신으로 설정한다
    latestCount.current = count;
    setTimeout(() => {
      // 변경 가능한 최신의 값을 읽어 들인다
      console.log(`You clicked ${latestCount.current} times`);
    }, 3000);
  });
  // ...

리액트로 어떠한 값을 직접 변경하는 것이 꺼림칙해 보입니다만, 리액트의 클래스 컴포넌트는 정확하게 이런 식으로 this.state 를 재할당하고 있습니다.

미리 잡아둔 props 및 state와는 달리 특정 콜백에서 latestCount.current 의 값을 읽어 들일 때 언제나 같은 값을 보장하지 않습니다. 정의된 바에 따라 이 값은 언제나 변경할 수 있습니다. 그렇기 때문에 이런 사용 방법은 기본 동작이 아니며, 여러분이 직접 가져다 써야 합니다.

그러면 클린업(cleanup)은 뭐지?

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

이전 이펙트는 새 prop과 함께 리랜더링 되고 난 뒤에 클린업됩니다.

리액트가 {id: 20} 을 가지고 UI를 랜더링한다.
브라우저가 실제 그리기를 한다. 화면 상에서 {id: 20} 이 반영된 UI를 볼 수 있다.
리액트는 {id: 10} 에 대한 이펙트를 클린업한다.
리액트가 {id: 20} 에 대한 이펙트를 실행한다.

컴포넌트가 랜더링 안에 있는 모든 함수는 (이벤트 핸들러, 이펙트, 타임아웃이나 그 안에서 호출되는 API 등) 랜더가 호출될 때 정의된 props와 state 값을 잡아둔다.

이렇게 리액트는 페인팅 이후 이펙트를 다루는게 기본이며 그 결과 앱을 빠르게 만들어 줍니다. 이전 props는 우리의 코드가 원한다면 남아 있습니다.

  • 이펙트의 클린업은 “최신” prop을 읽지 않고, 클린업이 정의된 시점의 랜더링에 있던 값을 읽는다.

라이프사이클이 아니라 동기화

모든 것은 목적지에 달렸지 여정에 달린게 아니다.

useEffect 는 리액트 트리 바깥에 있는 것들을 props와 state에 따라 동기화 할 수 있게 합니다.

  • 랜더링 순서는 중요하지 않다. 오직 결과물이 같아야 한다.

리액트에게 이펙트를 비교하는 법을 가르치기

useEffect(() => {
  document.title = 'Hello, ' + name;
}, [name]); // 우리의 의존성

리액트에게 의존성으로 거짓말하지 마라

컴포넌트에 있는 모든 값 중 그 이펙트에 사용될 값은 반드시 거기 있어야 한다
이런 문제를 해결하는 방법은 의존성을 제거하는 것이 아닙니다.

의존성으로 거짓말을 하면 생기는 일

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>{count}</h1>;
}
  • 이 예제는 숫자가 오로지 한 번만 증가한다.
  • 마운트 될 때만 실행하게 된다면, 자동으로 카운팅이 되어야하는데 왜 일까?

하지만 의존성 배열이 리액트에게 어떤 랜더링 스코프에서 나온 값 중 이펙트에 쓰이는 것 전부를 알려주는 힌트라고 인식한다면 말이 됩니다.

  • 첫 번째 랜더링에서 count 는 0
  • 따라서 첫 번째 랜더링의 이펙트에서 setCount(count + 1) 는 setCount(0 + 1) 이라는 뜻이 된다.
  • deps 를 [] 라고 정의했기 때문에 이펙트를 절대 다시 실행하지 않고, 결국 그로 인해 매 초마다 setCount(0 + 1) 을 호출

의존성을 솔직하게 적는 두 가지 방법

첫 번째 방법은 컴포넌트 안에 있으면서 이펙트에서 사용되는 모든 값이 의존성 배열 안에 포함되도록 고치는 것입니다.

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);
  • 이렇게 하면 문제를 해결하겠지만 count 값이 바뀔 때마다 인터벌은 해제되고 다시 설정되어 원치 않은 동작이라고 판단된다.

두 번째 전략은 이펙트의 코드를 바꿔서 우리가 원하던 것 보다 자주 바뀌는 값을 요구하지 않도록 만드는 것입니다.

이펙트가 자급자족 하도록 만들기

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);
  • 이전 상태를 기준으로 상태 값을 업데이트 하고 싶을 때는, setState 에 함수 형태의 업데이터를 사용하면 된다.

이 이펙트가 한 번만 실행되었다 하더라도, 첫 번째 랜더링에 포함되는 인터벌 콜백은 인터벌이 실행될 때마다 c => c + 1 이라는 업데이트 지침을 전달하는데 완벽하게 들어맞습니다. 더 이상 현재의 count 상태를 알고 있을 필요가 없습니다. 리액트가 이미 알고 있으니까요.

함수형 업데이트와 구글 닥스(Google Docs)

오로지 필요한 최소한의 정보를 이펙트 안에서 컴포넌트로 전달하는게 최적화에 도움이 됩니다.
(결과보다) 의도를 인코딩하는 것은 구글 닥스가 협동 편집 문제를 해결한 방법과 유사합니다.
하지만 setCount(c => c + 1) 조차도 그리 좋은 방법은 아닙니다.
예를 들어 서로에게 의존하는 두 상태 값이 있거나 prop 기반으로 다음 상태를 계산할 필요가 있을 때는 도움이 되지 않습니다. 다행히도 setCount(c => c + 1) 은 더 강력한 자매 패턴이 있습니다. 바로 useReducer 입니다.

액션을 업데이트로부터 분리하기

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

이 예제의 현재 동작은 step 이 변경되면 인터벌을 다시 시작하는 것입니다. 왜냐면 의존성으로 정의되어 있으니까요. 그리고 많은 경우에 이게 정확히 여러분이 원하던 동작일 겁니다! 이펙트를 분해하고 새로 설정하는데는 아무 문제가 없고, 특별히 좋은 이유가 있지 않다면 분해하는 것을 피하지 말아야 합니다.

  • 하지만 step 이 바뀐다고 인터벌 시계가 초기화되지 않는 것을 원한다면?
  • 이펙트의 의존성 배열에서 step 을 제거하려면 어떻게 해야할까?

어떤 상태 변수가 다른 상태 변수의 현재 값에 연관되도록 설정하려고 한다면, 두 상태 변수 모두 useReducer 로 교체해야 할 수 있습니다.

리듀서는 컴포넌트에서 일어나는 “액션”의 표현과 그 반응으로 상태가 어떻게 업데이트되어야 할지를 분리합니다.

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // setCount(c => c + step) 대신에
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

리액트는 컴포넌트가 유지되는 한 dispatch 함수가 항상 같다는 것을 보장합니다. 따라서 위의 예제에서 인터벌을 다시 구독할 필요조차 없습니다.

왜 useReducer가 Hooks의 치트 모드인가

  • 다음 상태를 계산하는데 props 가 필요하다면?
    • 리듀서 그 자체를 컴포넌트 안에 정의하여 props를 읽도록 한다.
function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, 0);
  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }
  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);
  return <h1>{count}</h1>;
}

이 경우조차 랜더링간 dispatch 의 동일성은 여전히 보장됩니다. 그래서 원한다면 이펙트의 의존성 배열에서 빼버릴 수도 있습니다. 이펙트가 재실행되도록 만들지 않을테니까요.

  • 어떻게 다른 랜더링에 포함된 이펙트 안에서 호출된 리듀서가 props를 “알고” 있지?”
  • A: dispatch 를 할 때에 있다.
  • 리액트는 그저 액션을 기억해 놓는다. 하지만 다음 랜더링 중에 리듀서를 호출할 것이다.
  • 이 시점에서 새 props가 스코프 안으로 들어오고 이펙트 내부와는 상관이 없게된다.

이래서 제가 useReducer 를 Hooks의 “치트 모드” 라고 생각하는 것입니다. 업데이트 로직과 그로 인해 무엇이 일어나는지 서술하는 것을 분리할 수 있도록 만들어줍니다. 그 다음은 이펙트의 불필요한 의존성을 제거하여 필요할 때보다 더 자주 실행되는 것을 피할 수 있도록 도와줍니다.

함수를 이펙트 안으로 옮기기

  • 흔한 실수 중 하나가 함수는 의존성에 포함 안해도 된다는 생각이다.

A common mistake is to think functions shouldn’t be dependencies.

function SearchResults() {
  const [query, setQuery] = useState('react');
  // 이 함수가 길다고 상상해 봅시다
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }
  // 이 함수가 길다고 상상해 봅시다
  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }
  useEffect(() => {
    fetchData();
  }, []);
  // ...
}

만약 이런 함수를 사용하는 어떠한 이펙트에도 deps를 업데이트하는 것을 깜빡했다면(아마도 다른 함수를 통해서요!), 이펙트는 prop과 state의 변화에 동기화하는데 실패할 것입니다. 그다지 좋지 않네요.

  • 어떠한 함수를 이펙트 안에서만 쓴다면, 그 함수를 직접 이펙트 안으로 옮긴다.
function SearchResults() {
  // ...
  useEffect(() => {
    // 아까의 함수들을 안으로 옮겼어요!
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=react';
    }
    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }
    fetchData();
  }, []); // ✅ Deps는 OK
  // ...
}
  • 나중에 getFetchUrl 을 수정하고 query state를 써야한다고 하면,
function SearchResults() {
  const [query, setQuery] = useState('react');
  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }
    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }
    fetchData();
  }, [query]); // ✅ Deps는 OK
  // ...
}

useEffect 의 디자인은 사용자가 제품을 사용하다 겪을 때까지 무시하는 대신, 데이터 흐름의 변화를 알아차리고 이펙트가 어떻게 동기화해야할지 선택하도록 강제합니다.

하지만 저는 이 함수를 이펙트 안에 넣을 수 없어요

먼저, 함수가 컴포넌트 스코프 안의 어떠한 것도 사용하지 않는다면, 컴포넌트 외부로 끌어올려두고 이펙트 안에서 자유롭게 사용하면 됩니다.

// ✅ 데이터 흐름에 영향을 받지 않는다
function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // ✅ Deps는 OK
  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // ✅ Deps는 OK
  // ...
}

혹은 useCallback 훅으로 감쌀 수 있습니다.

function SearchResults() {
  // ✅ 여기 정의된 deps가 같다면 항등성을 유지한다
  const getFetchUrl = useCallback((query) => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []);  // ✅ 콜백의 deps는 OK
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
  // ...
}
  • 입력을 받는 부분을 추가하여 임의의 query 를 검색할 수 있다면, query 를 인자로 받는 대신, getFetchUrl 이 지역 상태로부터 이를 읽어들인다.
function SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchUrl = useCallback(() => { // No query argument
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []); // 🔴 빠진 의존성: query
  // ...
}
function SearchResults() {
  const [query, setQuery] = useState('react');
  // ✅ query가 바뀔 때까지 항등성을 유지한다
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);  // ✅ 콜백 deps는 OK
  useEffect(() => {
    const url = getFetchUrl();
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
  // ...
}

이것은 그저 데이터 흐름과 동기화에 대한 개념을 받아들인 결과입니다. 부모로부터 함수 prop을 내려보내는 것 또한 같은 해결책이 적용됩니다.

function Parent() {
  const [query, setQuery] = useState('react');
  // ✅ query가 바뀔 때까지 항등성을 유지한다
  const fetchData = useCallback(() => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
    // ... 데이터를 불러와서 리턴한다 ...
  }, [query]);  // ✅ 콜백 deps는 OK
  return <Child fetchData={fetchData} />
}
function Child({ fetchData }) {
  let [data, setData] = useState(null);
  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); // ✅ 이펙트 deps는 OK
  // ...
}
  • fetchData 는 오로지 Parent 의 query 상태가 바뀔 때만 변하기 때문에, Child 컴포넌트는 앱에 꼭 필요할 때가 아니라면 데이터를 다시 페칭하지 않는다.

함수도 데이터 흐름의 일부인가?

  • class lifecycle
class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    // 🔴 이 조건문은 절대 참이 될 수 없다
    if (this.props.fetchData !== prevProps.fetchData) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}
  • binding을 한다면?
render() {
  return <Child fetchData={this.fetchData.bind(this, this.state.query)} />;
}
  • 하지만 이렇게 하면 query 가 바뀌지 않았는데도 this.props.fetchData !== prevProps.fetchData 는 언제나 true 가 될 것이다.
  • 결국 매번 데이터를 다시 페칭하게 된다.

진짜 클래스 컴포넌트로 이 수수께끼를 해결하는 방법은 이 꽉 깨물고 query 자체를 Child 컴포넌트에 넘기는 것 뿐입니다. Child 컴포넌트가 query 를 직접 사용하지 않음에도 불구하고 query 가 바뀔 때 다시 데이터를 불러오는 로직은 해결할 수 있습니다.

class Parent extends Component {
  state = {
    query: 'react'
  };
  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
    // ... 데이터를 불러와서 무언가를 한다 ...
  };
  render() {
    return <Child fetchData={this.fetchData} query={this.state.query} />;
  }
}
class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

클래스 컴포넌트에서, 함수 prop 자체는 실제로 데이터 흐름에서 차지하는 부분이 없습니다.
this 변수에 묶여 있기 때문에 함수의 일관성을 담보할 수 없게 됩니다.
부모 컴포넌트로부터 내려온 this.props.fetchData 가 어떤 상태에 기대고 있는지, 아니면 그냥 상태가 바뀌기만 한 것인지 알 수가 없습니다.

  • effect

useCallback 을 사용하면, 함수는 명백하게 데이터 흐름에 포함됩니다.
만약 함수의 입력값이 바뀌면 함수 자체가 바뀌고, 만약 그렇지 않다면 같은 함수로 남아있다고 말 할 수 있습니다. 세밀하게 제공되는 useCallback 덕분에 props.fetchData 같은 props 변화는 자동적으로 하위 컴포넌트로 전달됩니다.
그렇다고 useCallback 을 어디든지 사용하는 것은 꽤 투박한 방법이라고 강조하고 싶습니다.

경쟁 상태에 대해

class Article extends Component {
  state = {
    article: null
  };
  componentDidMount() {
    this.fetchData(this.props.id);
  }
  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.fetchData(this.props.id);
    }
  }
  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }
  // ...
}
  • 만약 제가 {id: 10} 으로 데이터를 요청하고 {id: 20} 으로 바꾸었다면, {id: 20} 의 요청이 먼저 시작된다.
  • 그래서 먼저 시작된 요청이 더 늦게 끝나서 잘못된 상태를 덮어씌울 수 있게된다.

이를 경쟁 상태라고 하며, (보통 비동기 호출의 결과가 돌아올 때까지 기다린다고 여기며) 위에서 아래로 데이터가 흐르면서 async / await 이 섞여있는 코드에 흔히 나타납니다. 여기서 데이터가 흘러간다는 말은 props나 state가 async 함수 안에서 바뀔 수 있다는 이야기입니다.

여러분이 사용하는 비동기 접근 방식이 취소 기능을 지원한다면 아주 좋습니다! 그러면 클린업 함수에서 바로 비동기 함수를 취소를 할 수 있습니다.

function Article({ id }) {
  const [article, setArticle] = useState(null);
  useEffect(() => {
    let didCancel = false;
    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }
    fetchData();
    return () => {
      didCancel = true;
    };
  }, [id]);
  // ...
}
@yjkwon07 yjkwon07 added the Hook Hook에 대한 고찰 label Oct 10, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Hook Hook에 대한 고찰
Projects
None yet
Development

No branches or pull requests

1 participant