새 단장한 React 다큐멘테이션 훑어보기

민경민·2023년 3월 18일
0
post-thumbnail

React 홈페이지 새 단장 완료!

3월 16일 부로 기존의 reactjs.org가 사라지고 새로운 react.dev 사이트가 오픈 되었다. 여전 부터 beta 상태로 최신 다큐멘테이션을 읽어 볼 수 있었지만, 이제 기존 다큐멘테이션은 완전히 레거시로 넘어가게 되었다. 모던한 React 개발을 매우 친절하게 설명하고 있기에, FE 개발자라면 한번 톺아보는 것을 추천한다. 이 게시글에서 글쓴이 기준으로 React에 대해 어느정도 알고 있는 개발자도 리마인드 하거나 새로 익히면 좋을 정보들만 정리 했다.

다큐멘테이션 웹사이트에서 FE 트렌드가 보인다

Next.js & Tailwind의 채택

wappalyzer라는 툴로 확인 해본 결과 새로 작성된 React 다큐멘테이션은 Next.js로 만들어 졌고, 배포 환경에는 Vercel이 사용되었다. 메타 프레임워크, 서버리스(엣지) 환경 트렌드가 떠올려진다.

또한 살펴볼점은 Tailwind CSS의 채택이다. Tailwind CSS는 Utility-first CSS 프레임워크의 대표 주자이다. SSR의 가장 중요한 목표중의 하나는 성능이다. 이에 CSS-in-JS의 런타임 오버헤드를 피할 수 있는 Tailwind CSS 또한 최근 채택이 증가되고 있다.

글쓴이가 말한 내용이 흥미롭다면, 다음 아티클들을 추가로 읽어보는 것을 추천한다.
10 Web Development Trends in 2023
Why We're Breaking Up with CSS-in-JS

CRA는 공사진행 중

공식문서에서 create-react-app에 대한 추천이 사라졌다. 이제 새로운 프로젝트를 시작하는 경우 Next.js, Remix.js 등 React 기반 메타 프레임 워크를 추천하고, React를 라이브러리로 사용하려는 경우 Vite를 번들러로 추천한다. React Core팀은 create-react-app라는 커맨드는 유지하되, 프레임워크를 추천 해주는 launcher로 수정하는 방향을 잡았다고 한다. 궁금하다면 Dan Abramov의 설명을 읽어보자.


"Quick Start" 섹션을 먼저 살펴보자

Function Component First!

기존의 React 튜토리얼은 틱택토 게임을 Class Component로 작성했다. Function Component 또한 사용했지만, 상태를 가지고 있지 않은 소위 Dumb(Presentational) Component로 사용되었다. hooks API가 존재하기 전 Presentational and Container Component Pattern의 잔재 였으리라. 업데이트 된 튜토리얼에서는 Function Component와 useState가 사용된다.

"Thinking in React"가 강조 되었다!

Thinking in React라는 파트가 Quick Start 섹션으로 이동 되었다. 디자인 목업과 JSON API가 정의 되어있다고 가정하고, React를 사용하는 개발자가 작업하는 프로레스를 권장/설명 해주는 섹션이다.

1. UI를 컴포넌트 계층 만들기

UI 목업을 보고, 필요한 컴포넌트와 하위 컴포넌트를 정의한다. 이상적으로 단일 책임 원칙에 따라 하나의 컴포넌트를 분해하여 여러개의 하위 컴포넌트로 이루어진 계층을 만든다.

2. React로 정적인 버전 만들기

유저 상호작용을 생각하지 않고, UI의 마크업을 진행한다. 이때 재사용 가능한 컴포넌트를 실제로 작성한다. 컴포넌트를 정의하면서 화면을 그리는데 필요한 prop 또한 정의한다. 간단한 컴포넌트 계층의 경우 top down으로 복잡한 계층의 경우 down up으로 작업하는 것이 추천된다.

3. UI 상태의 최소이지만 완전한 표현 찾기

상호작용을 구현하기 위한 state를 정의한다. 가장 중요한 점은 DRY 원칙에 따라 최소한의 state만 선언하는 것이다. 다른 state나 prop으로 부터 파생된 값은 render시 on-demand로 계산한다.

4. state가 어디에 위치 해야할지 결정하기

어느 컴포넌트에 어떤 state를 둘지 결정한다. 다음과 일반적으로 같은 과정을 통해 결정한다.

1. 특정 State에 따라 re-render 되어야하는 컴포넌트를 모두 찾는다.
2. 해당 컴포넌트들로 부터 가장 가까운 부모 컴포넌트에 state를 정의 한다.
3. State를 두는 것이 자연스러운 컴포넌트가 존재하지 않는다면 새로운 컴포넌트를 정의한다.

5. 역방향 데이터 흐름 추가하기

import { useState } from 'react';

function FilterableProductTable({ products }) {
  const [filterText, setFilterText] = useState('');
  const [inStockOnly, setInStockOnly] = useState(false);

  return (
    <div>
      <SearchBar 
        filterText={filterText} 
        inStockOnly={inStockOnly} 
        onFilterTextChange={setFilterText} 
        onInStockOnlyChange={setInStockOnly} />
      <ProductTable 
        products={products} 
        filterText={filterText}
        inStockOnly={inStockOnly} />
    </div>
  );
}

React에서 데이터 흐름은 단방향이다. 필요에 따라 부모의 state를 변경 시키는 setState 함수를 자식 컴포넌트에게 전달한다.


"Learn React" 섹션이 새로 생겼다!

Main Concepts 섹션이 Learn React 섹션으로 변경 되었다. 가장 많은 변화가 생긴 섹션이다. 유저편의를 위해 샘플 코드가 codesandbox에 연동되었고, 이해를 돕기 위한 비유와 일러스트레이션이 추가되었다.

특수한 상황이 아니면 이제 권장되지 않는 High-Order Component, PropTypes에 대한 설명이 삭제 되었다.

4가지 파트로 나누어져있는데 기존 다큐멘테이션에 비해서 매우 유기적으로 설명을 진행한다.

Describing the UI : jsx, property, list/conditional rendering
Adding Interactivity : event, state, render & commit
Managing State : declarative UI, state structure, context & reducer
Escape Hatches : ref, effect, custom hooks

Escape Hatches에서 다루는 주제는 고급 개념이다. useEffect 잘 쓰기 위해서 가장 중요한 규칙은, 최대한 useEffect의 사용을 피하는 것이라는 말도 있는데 다큐멘테이션도 이를 알려주려한다. 또한 useRef에 대한 자세한 설명DOM Manipulation으로 분리 되었다.

Pure Function & Strict Mode

let guest = 0;

function Cup() {
  // Bad: changing a preexisting variable!
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

함수가 컴포넌트를 작성하는 기본 방법으로 권장되면서, 순수함수와 컴포넌트가 순수함수이여야 하는 이유에 대한 설명이 추가되었다. 컴포넌트가 순수하지 않으면 render 될때마다 부수효과를 야기 하기 때문이다. 이를 반쯤 강제하기 위한 StrictMode가 존재하긴 했지만 이제 다큐멘테이션에서 순수하지 않은 컴포넌트의 위험성을 자세히 설명 해준다.

필요하면 Immer를 사용하자

// useState
const [person, setPerson] = useState({
  name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
});

function handleTitleChange(e) {
  setPerson({
    ...person,
    artwork: {
      ...person.artwork,
      title: e.target.value
    }
  });
}

// useImmer
const [person, updatePerson] = useImmer({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
  });

function handleTitleChange(e) {
  updatePerson(draft => {
    draft.artwork.title = e.target.value;
  });
}

필요하면 Immer를 사용하자. 일일히 spread 하지 않고 state의 불변성을 지킬 수 있게 도와준다.

Batching state Update

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

setState에 함수를 인자로 전달하면, state 업데이트가 batching 된다. React v18의 업데이트로 eventHandler 밖에서도 batching이 기본으로 적용된다.

state를 설계하는 규칙

관계가 있는 state는 묶어라

// 비추천
const [x, setX] = useState(0)
const [y, setY] = useState(0)

// 추천
const [position, setPosition] = useState({position})

모순된 state를 피해라

// 비추천
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

async function handleSubmit(e) {
  e.preventDefault();
  setIsSending(true);
  await sendMessage(text);
  setIsSending(false);
  setIsSent(true);
}

// 추천
const [status, setStatus] = useState('typing');

async function handleSubmit(e) {
  e.preventDefault();
  setStatus('sending');
  await sendMessage(text);
  setStatus('sent');
}

불필요한 state를 피해라

// 비추천
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

//추천
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

const fullName = firstName + ' ' + lastName;

state 복사를 피해라

// color가 업데이트 되지 않는다
import { useState } from 'react';

export default function Clock(props) {
  const [color, setColor] = useState(props.color);
  return (
    <h1 style={{ color: color }}>
      {props.time}
    </h1>
  );
}

// 가능하면 state 복사를 피해라
export default function Clock({color, time}) {
  return (
    <h1 style={{ color }}>
      {time}
    </h1>
  );
}

깊게 nesting 된 state를 피해라

// 비추천
export const initialTravelPlan = {
  id: 0,
  title: '(Root)',
  childPlaces: [{
    id: 1,
    title: 'Earth',
    childPlaces: [{
      id: 2,
      title: 'Africa',
      childPlaces: [{
        id: 3,
        title: 'Botswana',
        childPlaces: []
      }, {
        id: 4,
        title: 'Egypt',
        childPlaces: []
      }, ...                 
// 추천
export const initialTravelPlan = {
  0: {
    id: 0,
    title: '(Root)',
    childIds: [1, 43, 47],
  },
  1: {
    id: 1,
    title: 'Earth',
    childIds: [2, 10, 19, 27, 35]
  },
  2: {
    id: 2,
    title: 'Africa',
    childIds: [3, 4, 5, 6 , 7, 8, 9]
  }, ...

useEffect 사용이 필요한 경우들

non-React widget를 제어 할때

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

event에 구독 할때

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

애니메이션을 트리거 할때

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);

분석 정보를 로깅할 때

useEffect(() => {
  logVisit(url); // Sends a POST request
}, [url]);

useEffect 사용이 불필요한 경우들

Prop의 변경으로 state를 일괄 초기화 할때

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}
export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

Post requst 전송

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic should run because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Event-specific logic inside an Effect
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}
function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic runs because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: Event-specific logic is in the event handler
    post('/api/register', { firstName, lastName });
  }
  // ...
}

Data Fetching

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false; // ! avoid race condition
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

외부 저장소에 구독

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}
profile
I build stuff.

0개의 댓글