본 글은 카카오페이 프론트엔드 개발자들이React-Query를 선택한 이유요약한 글입니다.
자세한 내용은 React-Query 공식문서을 읽으시기 바랍니다.

React-Query 도입 이전

 많은 프로젝트들은 React-Query를 도입하기 전 서버와의 API 통신과 비동기 데이터 관리에 주로 Redux를 사용했다. 서비스 특성과 개발자의 취향에 따라 redux-thunk, redux-saga 등 다양한 Asynchronous Middleware를 채택하여 사용하고 있었으며, 더 효율적인 업무를 위한 다양한 Custom Middleware를 작성하여 사용하기도 했다.

Redux, 불편한 점

 위와 같은 환경에서 Redux를 사용하여 API 통신을 수행하고 비동기 데이터를 관리하며 얻은 다양한 장점이 존재했지만, 반대로 불편한 부분도 상당히 있었다. Redux는 “Global State Management Library” 이다. React Application을 개발함에 있어 일종의 De facto standard로써 여겨지고 있고 대부분의 React Application 개발 환경 설정 시 자연스럽게 Redux가 마치 React Stack의 일부인 것처럼 구성되곤 했다.

 이러한 현실 아래서 웹 프론트엔드에서 빈번하게 수행되는 API 통신에 Redux를 사용하는 것은 일견 자연스러운 선택이였다.

 비동기 데이터를 React Component의 State에 보관하게 될 경우 다수의 Component의 Lifecycle에 따라 비동기 데이터가 관리되므로 캐싱 등 최적화를 수행하기 어렵다. 그리고 다수의 Component에서 동일한 API를 호출하거나, 특정 API 응답이 다른 API에 영향을 미치는 경우 등 복잡하지만 빈번하게 요구되는 사용자 시나리오에 대응하기가 쉽지 않다.

 하지만 Global State management Library인 Redux를 사용하여 비동기 데이터를 관리할 경우 Component의 Lifecycle과 관계없이 Global State에서 비동기 데이터가 관리되기 때문에 캐싱과 같은 최적화 작업을 쉽게 수행할 수 있고 복잡한 사용자 시나리오에 대한 대응도 용이해지기 때문이다.

 카카오페이 프론트엔드 개발자들도 위와 같은 사유로 Redux로 API 통신과 비동기 데이터를 관리하고 있었으나 React Query를 접하고 난 뒤 다음과 같은 부분에서 (비동기 데이터 관리의 측면에서) React Query와 대비되는 Redux의 단점들을 느꼈다.

너무 장황한 Boilerplate 코드

 이미 모두가 알다시피, Redux에는 Redux 기본 원칙이 존재한다. 이 기본 원칙을 충족하기 위해서 Redux를 사용하는데는 장황한 Boilerplate 코드가 요구된다. 이러한 이슈를 해결하기 위한 redux-toolkit의 등장 이후 Boilerplate 코드가 많이 줄어들었음에도 불구하고 Redux로 비동기 데이터를 관리하는 일에는 여전히 불필요하게 느껴지는 반복되는 Boilerplate 코드가 필요하다.

API 요청 수행을 위한 규격화 된 방식 부재

 너무나 자명하게도, Redux는 API 통신 및 비동기 상태 관리를 위한 라이브러리가 아니다. Redux를 사용하여 비동기 데이터를 관리하기 위해서는 관련된 코드를 하나부터 열까지 개발자가 결정하고 구현해야 한다.

API 관련 상태 저장 방법이 가장 대표적인 예시입니다. 개발자의 선택에 따라 API 응답을 전부 State에 보관하고 Selector에서 필요한 값만 계산해서 사용할 수도 있고, 보관할 때부터 필요한 값만 State에 보관하는 경우도 있습니다. 더 나아가 API의 로딩 여부를 Boolean을 사용해서 관리하는 경우도 있고, IDLE | LOADING | SUCCESS | ERROR 등 상태를 세분화하여 관리하는 경우도 있다.

// 로딩 상태를 관리하는 방법도 개발자에 따라 다르게 구현됩니다.
interface ApiState {
  data?: Data;
  isLoading: boolean;
  error?: Error;
}

interface ApiState {
  data?: Data;
  status: 'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR';
  error?: Error;
}

 이는 Redux가 비동기 데이터를 관리하기 위한 전문 라이브러리가 아니라, 범용적으로 사용할 수 있는 전역 상태 관리 라이브러리여서 생겨나는 현상이다. Redux Middleware로 비동기 상태를 불러오고 그 값을 보관할 수는 있지만 내부적인 구현은 모두 개발자가 알아서 하다보니 상황에 따라 데이터를 관리하는 방식과 방법이 달라질 수 밖에 없다.

 이러한 방식과 방법에 정답은 없지만, 팀의 구성원이 많아지고 협업 관계가 복잡하게 구성될수록 자연스러운 방향으로 통일된다면 더 효율적인 업무가 가능할 것이다. 더 나아가 팀 구성원들이 동일한 방법과 방식에 익숙해지고 숙련도가 높아진다면 새로운 Best Practice가 발굴되어 더 나은 제품을 만드는 기반이 될 수 있을 것이다.

 이처럼 API 상태를 관리하기 위한 규격화된 방식이 있다면 더 좋은 제품을 보다 효율적으로 만들 수 있을 것이다. 다만 Redux를 사용하는 경우 구성원들의 환경과 경험이 다르고 프로젝트별 상황이 다르기 때문에 범용적인 방식을 발굴하기에 한계가 존재했다.

React Query 소개

위와 같은 Redux를 사용한 API 요청과 비동기 데이터 관리의 불편함을 해소하기 위해 카카오페이 프론트엔드 개발자들은 전향적으로 React Query를 도입하여 사용하고 있다.

 React Query는 React Application에서 서버의 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트 하는 작업을 도와주는 라이브러리입니다. React Query는 우리에게 친숙한 Hook을 사용하여 React Component 내부에서 자연스럽게 서버(또는 비동기적인 요청이 필요한 Source)의 데이터를 사용할 수 있는 방법을 제안한다.

 길고 거창한 설명 없이도 아래 샘플 코드를 한번 살펴보시면 React Query를 사용한 API 요청과 상태 관리가 얼마나 쉽고 자연스러운지 알 수 있다.

import axios from 'axios';
import {
  QueryClient,
  QueryClientProvider,
  useMutation,
  useQuery,
  useQueryClient,
} from 'react-query';

// React Query는 내부적으로 queryClient를 사용하여
// 각종 상태를 저장하고, 부가 기능을 제공합니다.
const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Menus />
    </QueryClientProvider>
  );
}

function Menus() {
  const queryClient = useQueryClient();

  // "/menu" API에 Get 요청을 보내 서버의 데이터를 가져옵니다.
  const { data } = useQuery('getMenu', () =>
    axios.get('/menu').then(({ data }) => data),
  );

  // "/menu" API에 Post 요청을 보내 서버에 데이터를 저장합니다.
  const { mutate } = useMutation(
    (suggest) => axios.post('/menu', { suggest }),
    {
      // Post 요청이 성공하면 위 useQuery의 데이터를 초기화합니다.
      // 데이터가 초기화되면 useQuery는 서버의 데이터를 다시 불러옵니다.
      onSuccess: () => queryClient.invalidateQueries('getMenu'),
    },
  );

  return (
    <div>
      <h1> Tomorrow's Lunch Candidates! </h1>
      <ul>
        {data.map((item) => (
          <li key={item.id}> {item.title} </li>
        ))}
      </ul>

      <button
        onClick={() =>
          mutate({
            id: Date.now(),
            title: 'Toowoomba Pasta',
          })
        }
      >
        Suggest Tomorrow's Menu
      </button>
    </div>
  );
}

React Query는 API 요청을 Query그리고 Mutation 이라는 두 가지 유형으로 나누어 생각합니다.

React Query의 Query 요청

// 가장 기본적인 형태의 React Query useQuery Hook 사용 예시
const { data } = useQuery(
  queryKey, // 이 Query 요청에 대한 응답 데이터를 캐시할 때 사용할 Unique Key (required)
  fetchFn, // 이 Query 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
  options, // useQuery에서 사용되는 Option 객체 (optional)
);

 useQuery Hook으로 수행되는 Query 요청은 HTTP METHOD GET 요청과 같이 서버에 저장되어 있는 “상태”를 불러와 사용할 때 사용한다. React Query의 useQuery Hook은 요청마다 (API마다) 구분되는 **Unique Key (aka. Query Key)**를 필요로 한다. React Query는 이 Unique Key로 서버 상태 (aka. API Response)를 로컬에 캐시하고 관리한다.

function Users() {
  const { isLoading, error, data } = useQuery(
    'userInfo', // 'userInfo'를 Key로 사용하여 데이터 캐싱
    // 다른 컴포넌트에서 'userInfo'를 QueryKey로 사용한 useQuery Hook이 있다면 캐시된 데이터를 우선 사용합니다.
    () => axios.get('/users').then(({ data }) => data),
  );

  // FYI, `data === undefined`를 평가하여 로딩 상태를 처리하는것이 더 좋습니다.
  // React Query는 내부적으로 stale-while-revalidate 캐싱 전략을 사용하고 있기 때문입니다.
  if (isLoading) return <div> 로딩중... </div>;
  if (error) return <div> 에러: {error.message} </div>;

  return (
    <div>
      {' '}
      {data?.map(({ id, name }) => (
        <span key={id}> {name} </span>
      ))}{' '}
    </div>
  );
}

React Query의 Mutation 요청

// 가장 기본적인 형태의 React Query useMutation Hook 사용 예시
const { mutate } = useMutation(
  mutationFn, // 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
  options, // useMutation에서 사용되는 Option 객체 (optional)
);

 useMutation Hook으로 수행되는 Mutation 요청은 HTTP METHOD POST, PUT, DELETE 요청과 같이 서버에 Side Effect를 발생시켜 서버의 상태를 변경시킬 때 사용한다. useMutationHook의 첫번째 파라미터는 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수이며, useMutation의 return 값 중 mutate(또는 mutateAsync) 함수를 호출하여 서버에 Side Effect를 발생시킬 수 있다.

function NotificationSwitch({ value }) {
  // mutate 함수를 호출하여 mutationFn 실행
  const { mutate, isLoading } = useMutation(
    (value) => axios.post(URL, { value }), // mutationFn
  );

  return (
    <Switch
      checked={value}
      disabled={isLoading}
      onChange={(checked) => {
        // mutationFn의 파라미터 'value'로 checked 값 전달
        mutate(checked);
      }}
    />
  );
}

React Query를 쓰고 이런 게 편해졌다.

Boilerplate 코드의 감소

앞에서 언급한대로, Redux를 사용할 경우 Redux의 기본 원칙 준수를 위한 다양한 Boilerplate 코드들이 필요하다. 더 나아가 (우리의 샘플 프로젝트에서는) API 상태 관리를 위해 하나의 API 요청을 3가지 Action을 사용해 처리하고 있고, 후에 기능이 추가되어 API 개수가 많아진다면 이런 상용구적인 코드도 함께 늘어나게 된다.

 단순히 비교해봐도 Redux를 사용한 비동기 데이터 관리 코드와 React Query를 사용한 비동기 데이터 관리 코드의 분량이 크게 차이남을 알 수 있다. 코드의 분량이 적어졌다는 것은 개발자에게 불필요한 작업이 필요 없어짐을 뜻하기도 하지만, 소스코드의 복잡도를 낮추어 유지보수의 용이성을 높이고 작업 간에 발생할 수 있는 사이드 이펙트나 휴먼 에러를 사전에 더 잘 막을 수 있다는 의미도 갖게 될 것이다.

API 요철 수행을 위한 규격화된 방식 제공

 앞에서 말씀드린 바와 같이 Redux는 비동기 데이터 관리를 위한 라이브러리가 아니다. Redux로 비동기 데이터를 관리하기 위해서 개발자들은 Middleware부터 State 구조까지 다양한 부분을 설계하고 구현해야 했다. 이러한 상황은 우리에게 하여금 불필요한 고민을 하게 만들고, 서로 간의 커뮤니케이션 비용을 증가시키는 요인으로 작동하기도 했다. 대부분의 케이스에 대응할 수 있는 편리하고 규격화된 방식을 제공한다면 이런 비효율적인 요소를 줄여 더 나은 제품을 만드는 방법에 집중할 수 있을 것이다.

 React Query는 React에서 비동기 데이터를 관리하기 위한 라이브러리 입니다. React Query는 API 요청 및 상태 관리를 위해 (상당히 잘 만들어진!) 규격화된 방식을 제공한다.

interface ApiState {
  data?: Data;
  isLoading: boolean;
  error?: Error;
}

interface ApiState {
  data?: Data;
  status: 'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR';
  error?: Error;
}

 React Query는 API 상태와 관련된 다양한 데이터를 제공하여 복잡한 구현과 설계 없이도 개발자가 효율적으로 화면을 구성할 수 있게끔 도와준다.

const {
  data,
  dataUpdatedAt,
  error,
  errorUpdatedAt,
  failureCount,
  isError,
  isFetched,
  isFetchedAfterMount,
  isFetching,
  isIdle,
  isLoading,
  isLoadingError,
  isPlaceholderData,
  isPreviousData,
  isRefetchError,
  isRefetching,
  isStale,
  isSuccess,
  refetch,
  remove,
  status,
} = useQuery(queryKey, queryFn);

사용자 경험 향상을 위한 기능 제공

 저희 카카오페이 프론트엔드 팀은 사용자 경험 향상을 위해 다양한 기법을 사용하고 있습니다. Redux로 비동기 데이터 관리를 할 때는 직접 구현해서 사용하곤 했는데, React Query는 자체적으로 제공하는 다양한 기능이 있어 이를 사용자 경험 향상에 손쉽게 사용할 수 있었습니다.

// Todo.tsx
function Todo() {
  const dispatch = useDispatch();

  // ...전략

  useEffect(() => {
    const handleVisibilityChange = () => {
      if (document.visibilityState === 'visible') {
        dispatch(requestFetchTodos());
      }
    }

    // window focus 이벤트 발생시 Todo API 요청
    document.addEventListener('visibilitychange', handleVisibilityChange);
    return () => document.removeEventListener('visibilitychange', handleVisibilityChange);

  }, [dispatch]);

  return (
    // ...후략
  );
}

export default Todo;

 앞에서 다룬 바와 같이 웹뷰 환경에서의 사용자 경험 향상을 위해 Window Focus 이벤트 발생 시 서버 상태를 동기화해야 하는 시나리오가 있다고 가정했을 때, Redux로 비동기 데이터 관리 시에서는 React Component 단에서 Window Focus 이벤트에 Dispatch Action을 직접 바인딩하여 구현해야 했다. 만약 소수의 컴포넌트에 이런 작업이 필요하다면 기꺼이 작업을 할 수 있겠지만, 여러 컴포넌트에서 여러 API에 걸쳐 이런 작업을 수행해야 한다면 유지보수 등 다양한 관점에서 부담스럽게 다가올 것이다.

// quires/useTodosQuery.ts
// API 상태를 불러오기 위한 React Query Custom Hook

// ...전략

const useTodosQuery = () => {
  return useQuery(QUERY_KEY, fetcher, { refetchOnWindowFocus: true });
};

export default useTodosQuery;

 React Query를 사용할 경우 단순한 옵션 부여만으로 Window Focus 이벤트 발생 시 서버 상태 동기화 시나리오를 달성할 수 있다. 다루는 API가 많아지고 컴포넌트 구조가 복잡해질수록 이전의 직접 Event Binding 하는 방식보다 유지보수하기 좋은 코드가 될 것이다.

 React Query와 함께라면 이 아티클에서 다룬 Refetch on window focus 외에 API Caching, API Retry, Optimistic Update, Persist Caching 등 사용자 경험 향상을 위한 다양한 기법들을 손쉽게 프로젝트에 포함시킬 수 있다.

 React Query에서 제공하는 이러한 기능들은 우리 개발자들로 하여금 제품과 직접적으로 연관되지 않는 작업에 투입해야 하는 리소스를 경감시켜 더 중요한 비즈니스 로직에 집중할 수 있게끔 도와준다. 이러한 환경은 우리가 더 견고한 제품을 만들 수 있는 바탕이 되어주고 있다.

TanStack Query v5

 React Query의 버전업으로 인해 위 예제와 비교하였을 때 문법이 조금 달라졌다.

function Example() {
  const { isPending, error, data } = useQuery({
    queryKey: ['repoData'],
    queryFn: () =>
      fetch('https://api.github.com/repos/TanStack/query').then((res) =>
        res.json(),
      ),
  })

옵션이 객체 형태로 들어가야한다. 

본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

 컴포넌트의 강점은 재사용 가능성에 있습니다. 즉, 다른 컴포넌트로 구성된 컴포넌트를 만들 수 있습니다. 그러나 점점 더 많은 컴포넌트를 중첩할수록 컴포넌트를 다른 파일로 분할하는 것이 합리적일 때가 많습니다. 이를 통해 파일을 쉽게 스캔하고 더 많은 위치에서 컴포넌트를 재사용할 수 있습니다. 여기서 우리는 다음과 같은 내용을 배웁니다.

  • 루트 컴포넌트 파일이란 무엇인지
  • 컴포넌트를 가져오고 내보내는 방법
  • default 및 named 가져오기 및 내보내기를 사용하는 경우
  • 컴포넌트를 내보내는 방법과 하나의 파일에서 여러 컴포넌트를 가져오는방법
  • 컴포넌트를 여러 파일로 분할하는 방법

루트 컴포넌트 파일

 컴포넌트 이해하기에서 Profile구성 요소와이를 렌더링하는 Gallery 구성 요소를 만들었습니다. 이들은 현재 이 예에서 App.js로 명명된 루트 컴포넌트 파일 에 있습니다. 하지만 설정에 따라 루트 구성 요소가 다른 파일에 있을 수도 있습니다. Next.js와 같은 파일 기반 라우팅이 포함된 프레임워크를 사용하는 경우 루트 구성 요소는 페이지마다 다릅니다.

컴포넌트 내보내기 및 가져오기

 나중에 랜딩 화면을 바꿔 과학도서 목록을 넣고 싶을때나 모든 프로필을 다른 곳에 배치하고 싶다면  Gallery 와 Profile 컴포넌트를 루트 컴포넌트 파일을  밖으로 내보내는 것이 좋습니다. 이렇게 하면 더 모듈화되고 다른 파일에서 재사용이 가능해집니다. 다음 세 단계로 구성요소를 이동할 수 있습니다.

  1. 컴포넌트를 넣을 새 JS 파일을 만듭니다 .
  2. 해당 파일에서 함수 구성 요소를 내보냅니다 ( 기본 내보내기(default) 또는 명명된(named) 내보내기 사용).
  3. 구성 요소를 사용할 파일로 가져옵니다 ( 기본(default) 또는 명명된(named) 내보내기 가져오기에 해당하는 기술 사용).

여기서 Profile 및 Gallery  컴포넌트 둘 다 Gallery.js 이라는 새 파일로 이동되었습니다. 이제 다음에서 가져오도록 변경할 수 있습니다 

// App.js 파일
import Gallery from './Gallery.js';

export default function App() {
  return (
    <Gallery />
  );
}
// Gallery.js 파일

function Profile() {
  return (
    <img
      src="https://i.imgur.com/QIrZWGIs.jpg"
      alt="Alan L. Hart"
    />
  );
}

export default function Gallery() {
  return (
    <section>
      <h1>Amazing scientists</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}

이제 이 예제가 두 개의 컴포넌트 파일로 어떻게 구분되는지 확인하세요.

  1. Gallery.js:
    • 동일한 파일 내에서만 사용되며 내보내지지 않는 Profile 컴포넌트 정의합니다 .
    • Gallery 컴포넌트를 기본 내보내기로 내보냅니다 .
  2. App.js:
    • Gallery.js 에서 Gallery컴포넌트를 기본 가져오기(default) 가져옵니다 .
    • 루트 App 컴포넌트를 기본 내보내기(default)로 내보냅니다 .
  • 다음과 같이 .js 파일 확장자가 없는 파일이 나타날 수 있습니다.
    './Gallery.js'또는'./Gallery' 는 React와 함께 작동하지만 전자가 기본 ES 모듈이 작동하는 방식에 더 가깝습니다.
import Gallery from './Gallery';

심화개념 : Default vs named exports

 JavaScript를 사용하여 값을 내보내는 두 가지 주요 방법은 기본 내보내기 default exports와 명명된 내보내기named exports입니다. 지금까지 예제에서는 기본 내보내기만 사용했습니다. 그러나 동일한 파일에서 둘 중 하나 또는 둘 다를 사용할 수 있습니다. 파일에는 기본 내보내기가 하나만 있을 수 있지만 이름이 지정된 내보내기는 원하는 만큼 많이 가질 수 있습니다.

 구성 요소를 내보내는 방법에 따라 가져오는 방법이 결정됩니다. 명명된 내보내기와 동일한 방식으로 기본 내보내기를 가져오려고 하면 오류가 발생합니다! 이 표는 다음을 추적하는 데 도움이 될 수 있습니다.

문법 Export statement Import statement
Defualt export default function Button() {} import Button from './Button.js';
Named export function Button() {} mport {Button} from './Button.js';

 기본 가져오기를 작성할 때 import 뒤에 원하는 이름을 넣을 수 있습니다 . 예를 들어, 위의 예시 대신  import Banana from './Button.js'을 쓸 수 있으며 여전히 동일한 기본 내보내기가 제공됩니다. 대조적으로, 명명된 가져오기의 경우 이름이 양쪽에서 일치해야 합니다.

 파일이 하나의 구성 요소만 내보내는 경우 사람들은 종종 기본 내보내기를 사용하고, 여러 구성 요소와 값을 내보내는 경우 명명된 내보내기를 사용합니다. 선호하는 코딩 스타일에 관계없이 항상 구성 요소 기능과 이를 포함하는 파일에 의미 있는 이름을 지정하세요. export default () => {} 와 같이 이름이 없는 구성 요소는 디버깅을 더 어렵게 만들기 때문에 권장되지 않습니다.

동일한 파일에서 여러 컴포넌트 내보내기 및 가져오기

 gallery가 아닌 Profile 하나만 보여주고 싶다면  Profile 컴포넌트를 내보낼 수도 있습니다. 그러나 Gallery.js는 이미 기본 내보내기가 있으므로 두 개의 기본 내보내기를 가질 수 없습니다 . 새 파일 생성하여 default imports를 사용하거나 Profile 컴포넌트를 named exports로 내보낼 수 있습니다.  파일에는 기본 내보내기가 하나만 있을 수 있지만 명명된 내보내기는 여러 개 있을 수 있습니다.

  • 기본 내보내기와 명명된 내보내기 간의 잠재적인 혼동을 줄이기 위해 일부 팀에서는 하나의 스타일(기본 또는 명명된)만 고수하거나 단일 파일에서 혼합하지 않도록 선택합니다. 당신에게 가장 적합한 일을 하세요.

먼저 named exports 를 사용하여 Profile 컴포넌트를 Gallery.js에서 내보냅니다.

export function Profile() {
  // ...
}

 그후, named import 를 사용하여 Gallery.js으로부터 Profile컴포넌트를 가져옵니다.

import { Profile } from './Gallery.js';

마지막으로 <Profile />를 App 컴포넌트에 렌더링합니다.

export default function App() {
  return <Profile />;
}

이제 Gallery.js 에서 default export Gallery와 named export Profile라는 두 가지 내보내기가 포함됩니다. App.js은 둘 다 가져옵니다.

// App.js 파일

import Gallery from './Gallery.js';
import { Profile } from './Gallery.js';

export default function App() {
  return (
    <Profile />
  );
}
// Gallery.js

export function Profile() {
  return (
    <img
      src="https://i.imgur.com/QIrZWGIs.jpg"
      alt="Alan L. Hart"
    />
  );
}

export default function Gallery() {
  return (
    <section>
      <h1>Amazing scientists</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}

이제 기본 내보내기와 명명된 내보내기를 혼합하여 사용하고 있습니다.

  • Gallery.js:
    • Profile 컴포넌트를  named export  내보냅니다 .
    • Gallery 컴포넌트를 default export 내보냅니다 .
  • App.js:
    • Profile 컴포넌트를 Gallery.js에서 named import 로 가져옵니다
    • Gallery 컴포넌트를 default import로 가져옵니다
    • 루트 App 컴포넌트를 default export로 내보냅니다.

과제 답안

// App.js 파일

import Gallery from './Gallery.js';
import Profile from './Profile.js';

export default function App() {
  return (
    <div>
      <Profile />
    </div>
  );
}
// Gallery.js 파일

import Profile from './Profile.js';

export default function Gallery() {
  return (
    <section>
      <h1>Amazing scientists</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}
// Profile.js 파일

export default function Profile() {
  return (
    <img
      src="https://i.imgur.com/QIrZWGIs.jpg"
      alt="Alan L. Hart"
    />
  );
}
본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

컴포넌트는 React의 중요한 개념 중 하나이며 사용자 인터페이스 UI를 구축하는 기초입니다. 여기서 우리는 다음과 같은 내용을 배웁니다.

  • 컴포넌트가 무엇인지
  • React 애플리케이션 내에서 컴포넌트의 역할
  • 첫번째 React 컴포넌트를 작성하는 방법

컴포넌트 : UI  빌딩 블록

웹에서 HTML을 사용하면 다음과 같은 내장된 태그 세트 <h1>,<li>를 사용하여 풍부하고 구조환된 문서를 만들 수 있습니다.

<article>
  <h1>My First Component</h1>
  <ol>
    <li>Components: UI Building Blocks</li>
    <li>Defining a Component</li>
    <li>Using a Component</li>
  </ol>
</article>

 이 마크업은 이 기사 <article>, 제목 <h1>및 <ol>(축약된) 목차를 순서가 지정된 목록으로 나타냅니다. 스타일을 위한 CSS, 상호작용을 위한 JavaScript와 결합된 이러한 마크업은 웹에서 볼 수 있는 모든 사이드바, 아바타, 모달, 드롭다운 등 UI의 모든 부분 뒤에 있습니다.

 React를 사용하면 마크업, CSS 및 JavaScript를 앱의 재사용 가능한 UI 요소인 사용자 정의 "구성 요소"로 결합할 수 있습니다. 위에서 본 목차 코드는 모든 페이지에서 렌더링할 수 있는 구성 요소 <TableOfContents />로 바뀔 수 있습니다. 내부적으로는 여전히 <article>, <h1>등과 같은 동일한 HTML 태그를 사용합니다.

 HTML 태그와 마찬가지로 구성 요소를 구성, 정렬 및 중첩하여 전체 페이지를 디자인할 수 있습니다. 예를 들어, 읽고 있는 문서 페이지는 React 구성 요소로 구성되어 있습니다.

<PageLayout>
  <NavigationHeader>
    <SearchBar />
    <Link to="/docs">Docs</Link>
  </NavigationHeader>
  <Sidebar />
  <PageContent>
    <TableOfContents />
    <DocumentationText />
  </PageContent>
</PageLayout>

 프로젝트가 성장함에 따라 이미 작성한 구성 요소를 재사용하여 많은 디자인을 구성하여 개발 속도를 높일 수 있다는 것을 알게 될 것입니다. 위의 목차는 <TableOfContents />!을 사용하여 어떤 화면에도 추가할 수 있습니다. Chakra UI 및 Material UI 와 같은 React 오픈 소스 커뮤니티에서 공유하는 수천 개의 구성 요소를 사용하여 프로젝트를 시작할 수도 있습니다.

구성 요소 정의

전통적으로 웹 페이지를 만들 때 웹 개발자는 콘텐츠를 표시한 다음 일부 JavaScript를 뿌려 상호 작용을 추가했습니다. 이는 웹에서 상호 작용이 있으면 훌륭하게 작동했습니다. React는 동일한 기술을 사용하면서도 상호작용을 최우선으로 생각합니다. React 컴포넌트는 마크업을 뿌릴 수 있는 JavaScript 함수입니다 . 그 모습은 다음과 같습니다.

export default function Profile() {
  return (
    <img
      src="https://i.imgur.com/MK3eW3Am.jpg"
      alt="Katherine Johnson"
    />
  )
}

구성요소를 빌드하는 방법은 다음과 같습니다.

1단계 : 컴포넌트 내보내기

접두사 export default 표준 JavaScript 구문입니다 (React에만 국한되지 않음). 나중에 다른 파일에서 가져올 수 있도록 파일의 주요 기능을 표시할 수 있습니다. ( 컴포넌트 가져오기 및 내보내기 에서 가져오기에 대해 자세히 알아보세요 .)

2단계 : 함수 정의

 function Profile() {} 로 Profile 라는 이름의 JavaScript 함수를 정의합니다.

  • React 구성 요소는 일반 JavaScript 함수이지만 이름은 대문자로 시작해야 하며 그렇지 않으면 작동하지 않습니다!

3단계 : 마크업 추가

 컴포넌트는 src 및 alt 속성이 포함된 <img/> 태그를 반환합니다 . HTML처럼 작성되었지만 실제로는 내부적으로는 JavaScript입니다! 이 구문을 JSX 라고 하며 이를 사용하면 JavaScript 내에 마크업을 삽입할 수 있습니다. 마크업이 모두 return 키워드와 같은 줄에 있지 않으면 마크업을 한 쌍의 괄호로 묶어야 합니다.

return (
  <div>
    <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />
  </div>
);
  • 괄호가 없으면 다음 줄의 모든 코드는 return 무시됩니다 !

컴포넌트 사용

 이제 컴포넌트를 정의했으므로 Profile를 다른 컴포넌트 내에 중첩할 수 있습니다. 예를 들어 여러 Profile 컴포넌트를 사용하는 Gallery 컴포넌트를 내보낼 수 있습니다.

function Profile() {
  return (
    <img
      src="https://i.imgur.com/MK3eW3As.jpg"
      alt="Katherine Johnson"
    />
  );
}

export default function Gallery() {
  return (
    <section>
      <h1>Amazing scientists</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}

브라우저 관점에서 보기

 대소문자의 차이점을 확인하세요

  • <section> 소문자이므로 React는 우리가 HTML 태그를 참조한다는 것을 알고 있습니다.
  • <Profile /> 대문자 P로 시작하므로 React는 우리가 Profile이라는 컴포넌트를 사용하고 싶다는 것을 알고 있습니다 .

그리고 Profile은 더 많은 HTML을 포함합니다: <img />. 결국 브라우저에는 다음과 같은 내용이 표시됩니다.

<section>
  <h1>Amazing scientists</h1>
  <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />
  <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />
  <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />
</section>

 

컴포넌트 중첩 및 구성하기

 컴포넌트는 일반 JavaScript 함수이므로 동일한 파일에 여러 구성 요소를 유지할 수 있습니다. 이는 컴포넌트가 상대적으로 작거나 서로 밀접하게 관련되어 있는 경우 편리합니다. 이 파일이 복잡해지면 언제든지 Profile을 별도의 파일로 이동시킬 수 있습니다. Gallery 컴포넌트는 내부에서 여러 번 컴포넌트가 렌더링되기 때문에 상위 구성 요소라고 말할 수 있으며 각 구성 요소  Profile 컴포넌트를 " 자식"으로 렌더링합니다. 컴포넌트를 한 번 정의하면 원하는 만큼 여러 장소에서 사용할 수 있습니다.

  • 구성 요소는 다른 구성 요소를 렌더링할 수 있지만 정의를 중첩해서는 안 됩니다.
export default function Gallery() {
  // 🔴 컴포너트를 다른 컴포넌트 안에서 정의하지 마세요!
  function Profile() {
    // ...
  }
  // ...
}
export default function Gallery() {
  // ...
}

// ✅ 최상위 레벨에서 컴포넌트를 선언하세요!
function Profile() {
  // ...
}

 하위 구성 요소에 상위 구성 요소의 일부 데이터가 필요한 경우 중첩 정의 대신 소품으로 전달하세요 .

심화 개념 : 컴포너트 파헤치기

 React 애플리케이션은 "root" 컴포넌트에서 시작됩니다. 일반적으로 새 프로젝트를 시작하면 자동으로 생성됩니다. 예를 들어 CodeSandbox를 사용하거나 Next.js 프레임워크를 사용하는 경우 루트 구성 요소는 pages/index.js.로 정의됩니다.

 대부분의 React 앱은 컴포넌트를 끝까지 사용합니다. 즉, 버튼과 같은 재사용 가능한 부분뿐만 아니라 사이드바, 목록, 최종적으로는 전체 페이지와 같은 더 큰 부분에도 컴포넌트를 사용할 수 있습니다. 컴포넌트는 일부가 한 번만 사용되더라도 UI 코드와 마크업을 구성하는 편리한 방법입니다.

 React 기반 프레임워크는 이를 한 단계 더 발전시킵니다. 빈 HTML 파일을 사용하고 React가 JavaScript로 페이지 관리를 "인계"하도록 하는 대신, React 구성 요소에서 자동 으로 HTML을 생성합니다. 이를 통해 JavaScript 코드가 로드되기 전에 앱에서 일부 콘텐츠를 표시할 수 있습니다.

 그럼에도 불구하고 많은 웹사이트는 기존 HTML 페이지에 상호작용성을 추가하기 위해서만 React를 사용합니다. 전체 페이지에 대해 단일 루트 구성 요소 대신 많은 루트 구성 요소가 있습니다. 필요한 만큼 React을 사용할 수 있습니다

요약

  • React를 사용하면 앱의 재사용 가능한 UI 요소인 구성 요소를 만들 수 있습니다 .
  • React 앱에서 UI의 모든 부분은 구성 요소입니다.
  • React 구성요소는 다음을 제외한 일반 JavaScript 함수입니다.
    1. 그들의 이름은 항상 대문자로 시작됩니다.
    2. JSX 마크업을 반환합니다.

과제 답안

// 1.컴포넌트 내보내기

export default function Profile() {
  return (
    <img
      src="https://i.imgur.com/lICfvbD.jpg"
      alt="Aklilu Lemma"
    />
  );
}
// 2. return 문을 수정하세요

export default function Profile() {
  return <img src="https://i.imgur.com/jA8hHMpm.jpg" alt="Katsuko Saruhashi" />;
}
// 3. 컴포넌트 선언 실수 찾기

function Profile() {
  return (
    <img
      src="https://i.imgur.com/QIrZWGIs.jpg"
      alt="Alan L. Hart"
    />
  );
}

export default function Gallery() {
  return (
    <section>
      <h1>Amazing scientists</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}
// 4.컴포넌트 만들기

// Write your component below!
export default function Congratulation(){
  return(
    <h1>
      Good job!
    </h1>
  )
}
본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

 React은 우리가 보는 디자인과 빌드하는 앱에 대한 생각을 바꿀 수 있습니다. React를 사용하여 사용자 인터페이스를 빌드할 떄, 먼저 컴포넌트라고 불리는 조각으로 나눕니다. 그럼 다음, 각 컴포넌트의 다른 시각적 상태를 설명합니다. 마지막으로, 데이터가 컴포넌트를 통해 흐르도록 컴퍼넌트를 연결합니다. 이 과정에서는 React를 사용하여 검색 가능한 제품 데이터 테이블을 빌드하는 과정을 소개합니다. 

목업 디자인과 예상 데이터

우리는 현재 JSON API와 목업 디자인을 예상하고 있습니다. JSON API에서 반환되는 데이터는 이와 같습니다.

[
  { category: "Fruits", price: "$1", stocked: true, name: "Apple" },
  { category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
  { category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
  { category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
  { category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
  { category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
]

목업 디자인은 이와 같습니다.

React에서 UI를 구현할려면 일반적으로 다섯 단계를 따릅니다.

1. 컴포넌트 계층으로 UI 분할하기

 먼저 목업의 모든 컴포넌트와 하위 컴포넌트의 이름을 지정합니다. 디자이너와 협업하는 경우, 아마 디자인 툴에서 이러한 컴포넌트에 이름을 지정햇을 수도 있습니다. 배경에 따라 디자인을 컴포넌트로 나누는 방법을 다르게 생각할 수 있습니다 :

  • 프로그래밍 - 새로운 함수 또는 객체를 생성해야 할지를 결정하는 데 동일한 기술을 사용합니다. 하나의 기술로는 단일 책임 원칙이 있습니다. 즉, 구성 요소는 이상적으로는 하나의 일만 수행해야 합니다. 컴포넌트가 비대해진다면, 더 작은 컴포넌트로 분해되어야 합니다.
  • CSS - 클래스 선택자를 만들 것인지를 고려하십시오. (그러나 컴포넌트는 조금 덜 세분화됩니다.)
  • 디자인 - 디자인의 레이어를 어떻게 구성할 것인지를 고려하십시오. 

 JSON이 잘 구조화되어 있다면, UI의 컴포넌트 구조에 자연스럽게 매핑될 것입니다. UI와 데이터 모델은 종종 동일한 정보 아키텍처, 즉 동일한 형태를 가지고 있기 때문입니다. 각 컴포넌트가 데이터 모델의 하나의 조각과 일치하는지 여부에 따라 UI를 컴포넌트로 분리하십시오.

 스크린에는 다섯 컴포넌트가 있습니다.

  1. FilterableProductTable (회색)은 전체 앱을 포함합니다.
  2. SearchBar (파랑)는 사용자 입력을 받습니다.
  3. ProductTable (라벤더)은 사용자 입력에 따라 목록을 표시하고 필터링합니다.
  4. ProductCategoryRow (초록)는 각 카테고리에 대한 제목을 표시합니다.
  5. ProductRow (노랑)은 각 제품에 대한 행을 표시합니다.

 ProductTable (라벤더)을 살펴보면 "Name"과 "Price" 라벨을 포함한 테이블 헤더가 자체 컴포넌트가 아닌 것을 볼 수 있습니다. 이는 개인적인 선호에 따라 결정되며 두 가지 방법 중 하나를 선택할 수 있습니다. 이 예에서는 ProductTable의 리스트 내에 나타나기 때문에 ProductTable의 일부로 취급됩니다. 그러나 이 헤더가 복잡해질 경우 (예: 정렬 추가), 별도의 ProductTableHeader 컴포넌트로 이동할 수 있습니다.  목업에서 컴포넌트를 식별했으므로 이제 계층 구조로 정리할 수 있습니다. 목업에서 다른 컴포넌트 내에 나타나는 컴포넌트는 계층 구조에서 해당 컴포넌트의 하위로 나타나야 합니다:

  • FilterableProductTable
    • SearchBar
    • ProductTable
      • ProductCategoryRow
      • ProductRow

2. React에서 정적 버전 빌드하기

 이제 컴포넌트 계층구조가 준비되었으므로 앱을 구현할 때입니다. 가장 간단한 방법은 아직 상호 작용을 추가하지 않은 데이터 모델에서 UI를 렌더링하는 버전을 먼저 구축하는 것입니다. 정적 버전을 먼저 만들고 나중에 상호 작용을 추가하는 것이 종종 더 쉽습니다. 정적 버전을 만드는 것은 많은 타이핑을 필요로하지만 생각을 많이 하지 않으며, 상호 작용을 추가하는 것은 많은 생각을 필요로하지만 타이핑을 많이 필요로하지 않습니다.

 앱의 정적 버전을 구축하여 데이터 모델을 렌더링하려면 다른 컴포넌트를 재사용하고 props를 사용하여 데이터를 전달하는 컴포넌트를 만들어야 합니다. Props는 부모에서 자식으로 데이터를 전달하는 방법입니다. (상태(state)의 개념에 익숙하다면 이 정적 버전을 구축할 때 상태를 사용하지 마십시오. 상태는 상호 작용, 즉 시간이 지남에 따라 변경되는 데이터에 대해서만 사용됩니다. 이것은 앱의 정적 버전이므로 필요하지 않습니다.)

 위에서 아래로(top down)" 방식으로 시작하여 계층 구조에서 더 높은 위치의 컴포넌트 (예: FilterableProductTable)를 먼저 구축하거나 "아래에서 위로(bottom up)" 방식으로 시작하여 더 낮은 위치의 컴포넌트 (예: ProductRow)에서 작업할 수 있습니다. 더 간단한 예제에서는 보통 위에서 아래로 진행하는 것이 더 쉽지만, 더 큰 프로젝트에서는 아래에서 위로 진행하는 것이 더 쉽습니다.

function ProductCategoryRow({ category }) {
  return (
    <tr>
      <th colSpan="2">
        {category}
      </th>
    </tr>
  );
}

function ProductRow({ product }) {
  const name = product.stocked ? product.name :
    <span style={{ color: 'red' }}>
      {product.name}
    </span>;

  return (
    <tr>
      <td>{name}</td>
      <td>{product.price}</td>
    </tr>
  );
}

function ProductTable({ products }) {
  const rows = [];
  let lastCategory = null;

  products.forEach((product) => {
    if (product.category !== lastCategory) {
      rows.push(
        <ProductCategoryRow
          category={product.category}
          key={product.category} />
      );
    }
    rows.push(
      <ProductRow
        product={product}
        key={product.name} />
    );
    lastCategory = product.category;
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar() {
  return (
    <>
      <form>
        <input type="text" placeholder="Search..." />
      </form>
        <label>
          <input type="checkbox" />
          {' '}
          Only show products in stock
        </label>
    </>
  );
}

function FilterableProductTable({ products }) {
  return (
    <div>
      <SearchBar />
      <ProductTable products={products} />
    </div>
  );
}

const PRODUCTS = [
  {category: "Fruits", price: "$1", stocked: true, name: "Apple"},
  {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
  {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
  {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
  {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
  {category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];

export default function App() {
  return <FilterableProductTable products={PRODUCTS} />;
}

 컴포넌트를 구축한 후에는 데이터 모델을 렌더링하는 재사용 가능한 컴포넌트 라이브러리가 생성됩니다. 이 정적 앱에서 컴포넌트는 JSX만 반환합니다. 계층구조의 맨 위에 있는 컴포넌트 (FilterableProductTable)는 데이터 모델을 props으로 받습니다. 이것을 단방향 데이터 흐름이라고 합니다. 왜냐하면 데이터가 최상위 컴포넌트에서 하위 트리의 컴포넌트로 흐르기 때문입니다.

3. UI 상태 최적화하기

UI를 상호작용 가능하게 만들기 위해 사용자가 기본 데이터 모델을 변경할 수 있어야 합니다. 이를 위해 상태를 사용할 것입니다.

 상태를 변경할 필요가 있는 최소한의 데이터 세트로 생각하세요. 상태를 구조화하는 가장 중요한 원칙은 DRY(Don't Repeat Yourself)를 유지하는 것입니다. 응용 프로그램이 필요로하는 상태의 절대 최소 표현을 찾고, 모든 나머지 것을 요청시 계산하세요. 예를 들어, 쇼핑 목록을 작성 중이라면 항목을 배열로 상태에 저장할 수 있습니다. 목록에 항목 수를 표시하려면 항목 수를 다른 상태 값으로 저장하지 마세요. 대신 배열의 길이를 읽으세요.

 이 예제 응용 프로그램의 모든 데이터 조각을 생각해 보십시오:

  1. 원래 제품 목록
  2. 사용자가 입력 한 검색 텍스트
  3. 체크 박스의 값
  4. 필터링된 제품 목록

다음 항목은 상태가 아닙니다:

  • 시간이 지나도 변함없이 유지되는 것
  • props를 통해 부모로부터 전달되는 것
  • 컴포넌트의 기존 상태나 props을 기반으로 계산할 수 있는 것

하나씩 다시 살펴보겠습니다.

  • 원래 제품 목록은 props로 전달되므로 상태가 아닙니다.
  • 검색 텍스트는 시간이 지남에 따라 변경되고 다른 정보에서 계산할 수 없으므로 상태로 보입니다.
  • 체크박스의 값도 시간이 지남에 따라 변경되고 다른 정보에서 계산할 수 없으므로 상태로 보입니다.
  • 필터링된 제품 목록은 검색 텍스트와 체크박스의 값에 따라 계산될 수 있으므로 상태가 아닙니다.

따라서 검색 텍스트와 체크박스의 값만 상태입니다.

  • Props VS State : React 두 가지 모델데이터 props와 state는 매우 다릅니다
    • Props는 함수에 전달하는 인수와 같습니다. 부모 컴포넌트가 데이터를 자식 컴포넌트에 전달하고 외관을 사용자 정의하는 데 사용됩니다. 예를 들어, Form이 Button에 색상 prop을 전달할 수 있습니다.
    • State는 컴포넌트의 메모리와 같습니다. 컴포넌트가 일부 정보를 추적하고 상호 작용에 응답하여 해당 정보를 변경할 수 있도록 합니다. 예를 들어, Button은 isHovered 상태를 추적할 수 있습니다.
    • Props와 state는 다르지만 함께 작동합니다. 부모 컴포넌트는 종종 일부 정보를 상태로 유지하고 (그래서 그것을 변경할 수 있음) 이를 자식 컴포넌트에 props로 전달합니다.

4. 상태 위치 인식하기

 앱의 최소 상태 데이터를 식별한 후 이 상태를 변경하거나 소유하는 컴포넌트를 식별해야 합니다. React는 단방향 데이터 흐름을 사용하여 데이터를 부모 컴포넌트에서 자식 컴포넌트로 전달합니다. 어떤 컴포넌트가 어떤 상태를 소유해야 하는지 즉시 명확하지 않을 수 있습니다. 

 앱의 각 상태에대 다음을 적용합니다.

  1. 해당 상태를 기반으로 렌더링하는 모든 컴포넌트를 식별합니다.
  2. 그들의 가장 가까운 공통 상위 부모 컴포넌트 : 계층구조에서 모든 컴포넌트 위에있는 컴포넌트를 찾습니다.
  3. 상태를 어디에 두어야 할지 결정합니다.
    1. 종종 해당 상태를 바로 공통 부모 컴포넌트에 넣을 수 있습니다.
    2. 또한 해당 상태를 공통 부모 컴포넌트 위의 어떤 컴포넌트에 넣을 수도 있습니다.
    3. 상태를 소유할 수있는 컴포넌트를 찾을 수 없는 경우 상태를 보유하기 위해 새로운 컴포넌트를 생성하고 공통 부모 컴포넌트 위에있는 계층구조의 어딘가에 추가합니다.

 이전 단계에서 이 애플리케이션에서 두 가지 상태 조각을 찾았습니다: 검색 입력 텍스트와 체크박스의 값입니다. 이 예에서는 항상 함께 나타나므로 이를 동일한 위치에 넣는 것이 합리적입니다. 따라서 이러한 두 상태를 공통 상위 부모 컴포넌트인 FilterableProductTable 내부에 두는 것이 적절할 것입니다. 이렇게하면 두 상태 모두에 액세스 할 수 있으며 필요에 따라 하위 컴포넌트에 전달할 수 있습니다.

이제 다음과 같은 전략을 실행해 보겠습니다.

  1. 상태를 사용하는 컴포넌트 식별 :
    • ProductTable은 해당 상태 (검색 텍스트 및 체크박스 값)를 기반으로 제품 목록을 필터링해야 합니다.
    • SearchBar는 해당 상태 (검색 텍스트 및 체크박스 값)를 표시해야 합니다.
  2. 공통 부모 찾기: 두 컴포넌트가 공유하는 첫 번째 부모 컴포넌트는 FilterableProductTable입니다.
  3. 상태가 존재하는 위치 결정: 필터 텍스트 및 체크 상태 값을 FilterableProductTable에 유지합니다.

 따라서 상태 값은 FilterableProductTable에 존재할 것입니다. useState() Hook을 사용하여 컴포넌트에 상태를 추가합니다. Hook은 React에 "hook into"할 수있는 특수 함수입니다. FilterableProductTable의 상단에 두 개의 상태 변수를 추가하고 초기 상태를 지정합니다.

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

 그런 다음 filterText 및 inStockOnly를 props로 ProductTable과 SearchBar에 전달하십시오:

<div>
  <SearchBar 
    filterText={filterText} 
    inStockOnly={inStockOnly} />
  <ProductTable 
    products={products}
    filterText={filterText}
    inStockOnly={inStockOnly} />
</div>

 이제 애플리케이션이 어떻게 동작할지를 확인할 수 있습니다. 아래의 샌드박스 코드에서 useState('')에서 useState('fruit')로 filterText 초기 값을 편집합니다. 그러면 검색 입력 텍스트와 테이블이 모두 업데이트되는 것을 볼 수 있습니다.

function ProductTable({ products, inStockOnly, filterText }) {
  const rows = [];
  let lastCategory = null;

  products.forEach((product) => {

    if (
      product.name.toLowCase().indexof(
        filterText.toLowCase()
      ) === -1
    ){
      return ;
    }

    if (inStockOnly && !product.stocked) {
      return;
    }
    
    if (product.category !== lastCategory) {
      rows.push(
        <ProductCategoryRow
          category={product.category}
          key={product.category} />
      );
    }
    rows.push(
      <ProductRow
        product={product}
        key={product.name} />
    );
    lastCategory = product.category;
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Price</th>
        </tr>
      </thead>
      <tbody>{rows}</tbody>
    </table>
  );
}

function SearchBar({filterText, inStockOnly}) {
  return (
    <>
      <form>
        <input type="text" placeholder="Search..."  value={filterText}/>
      </form>
        <label>
          <input type="checkbox"  checked={inStockOnly}/>
          {' '}
          Only show products in stock
        </label>
    </>
  );
}

You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field.

 위의 샌드박스에서 ProductTable과 SearchBar는 filterText 및 inStockOnly props를 읽어서 테이블, 입력란 및 체크박스를 렌더링합니다. 예를 들어, 다음은 SearchBar가 입력 값(value)을 채우는 방법입니다. 그러나 아직 사용자의 타이핑과 같은 작업에 응답하는 코드를 추가하지 않았습니다. 이것이 마지막 단계가 될 것입니다.

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

 현재 앱은 props 및 상태가 계층 구조를 따라 올바르게 전달되어 렌더링됩니다. 그러나 사용자 입력에 따라 상태를 변경하려면 다음과 같은 역방향 데이터 흐름을 지원해야 합니다. 계층 구조 깊은 곳에 있는 폼 컴포넌트가 FilterableProductTable에서 상태를 업데이트해야 합니다.

 React는 이 데이터 흐름을 명시적으로 만들지만, 양방향 데이터 바인딩보다 조금 더 많은 타이핑이 필요합니다. 위 예제에서 입력란에 글자를 입력하거나 체크박스를 선택하려고 하면 React가 입력을 무시하는 것을 볼 수 있습니다. 이것은 의도적입니다. <input value={filterText} />와 같이 작성함으로써 입력의 값 prop을 항상 FilterableProductTable에서 전달된 filterText 상태와 동일하게 설정했습니다. filterText 상태가 설정되지 않았기 때문에 입력이 변경되지 않습니다.

 사용자가 양식 입력을 변경할 때마다 상태가 해당 변경 사항을 반영하도록하려면 상태가 FilterableProductTable에 의해 소유되므로 setFilterText 및 setInStockOnly를 호출 할 수있는 유일한 컴포넌트는 FilterableProductTable입니다. SearchBar가 FilterableProductTable의 상태를 업데이트할 수 있도록하려면 이러한 함수를 SearchBar로 전달해야합니다.

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

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

 SearchBar 내부에는 onChange 이벤트 핸들러를 추가하고 해당 이벤트 핸들러에서 부모 상태를 설정합니다.

function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange
}) {
  return (
    <form>
      <input
        type="text"
        value={filterText}
        placeholder="Search..."
        onChange={(e) => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={(e) => onInStockOnlyChange(e.target.checked)}

이제 완벽하게 작동합니다. 

본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

 튜토리얼을 통해 간단한 Tic-Tac-Toe 게임을 구현합니다. 기존의 React 지식을 요구하지 않으며 구현을 통해 React 구축에 기본이 되는 개념들을 이해할 수 있습니다. 튜토리얼은 4가지 섹션으로 나누어져 있습니다.

  1. 튜토리얼 설정은 튜토리얼을 따라가기 위한 시작점을 제공합니다.
  2. 구현하기에서는 React의 기본요소인 컴포넌트, props, state에 대해 공부합니다.
  3. 게임 완성에서는 React 개발에 대해 가장 기본적인 기술을 공부합니다.
  4. 시간여행 추가에서는 React의 고유한 강점에 대해 더욱 깊게 알아봅니다.

앞선 1,2 섹션은 Tutorial : Tic - Tac -Toe(1) , 3섹션은 Tutorial : Tic - Tac -Toe(2)를 참고하시길 바랍니다.

4. 시간 여행 추가

 마지막으로, 이전 게임 움직임으로 "시간을 되돌리는" 것이 가능하도록 만들어 보겠습니다.

 

4.1 수의 기록 저장하기

 만약 직접적으로 squares 배열을 변경했다면, 시간 여행을 구현하는 것은 매우 어려웠을 것입니다. 그러나 매수마다 slice()를 이용해 배열의 복사본을 만들어 불변성을 유지하였습니다. 이렇게 하면 square 배열의 과거 버전을 모두 저장하고, 이미 발생한 수들 사이를 이동할 수 있습니다. 과거의 squares 배열을 다른 배열인 history에 저장할 것입니다. 이를 새로운 상태 변수로 저장할 것입니다. history 배열은 첫 번째 움직임부터 마지막 움직임까지의 모든 보드 상태를 나타내며, 다음과 같은 형태를 가지고 있습니다

[
  // Before first move
  [null, null, null, null, null, null, null, null, null],
  // After first move
  [null, null, null, null, 'X', null, null, null, null],
  // After second move
  [null, null, null, null, 'X', null, null, null, 'O'],
  // ...
]

4.2 상태 끌어올리기

이제 과거 움직임 목록을 표시할 새로운 최상위 컴포넌트인 Game을 작성할 것입니다. 여기에는 전체 게임 이력을 포함하는 history 상태가 위치합니다. Game 컴포넌트에 history 상태를 배치하면 자식인 Board 컴포넌트에서 squares 상태를 제거할 수 있습니다. 마치 Square 컴포넌트에서 Board 컴포넌트로 상태를 '올리는 것'과 같이, 이제는 Board에서 최상위 Game 컴포넌트로 상태를 '올립니다'. 이렇게 하면 Game 컴포넌트가 Board의 데이터를 완전히 제어하고 이전 턴을 history에서 가져와 Board에 렌더링할 수 있습니다.

 

 export default 키워드가 function Board() 선언 앞에서 삭제되고, function Game() 선언 앞에 추가됩니다. 이는 index.js 파일이 보드 컴포넌트 대신 게임 컴포넌트를 최상위 컴포넌트로 사용하도록 지시합니다. 게임 컴포넌트에서 반환된 추가적인 div는 이후 보드에 추가할 게임 정보에 대한 공간을 확보합니다.

function Board() {
  // ...
}

export default function Game() {
  return (
    <div className="game">
      <div className="game-board">
        <Board />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  );
}

 Game 컴포넌트에 상태를 추가하여 다음 플레이어와 움직임의 히스토리를 추적합니다. 현재 움직임의 사각형을 렌더링하려면 history에서 마지막 squares 배열을 읽어야 합니다. 이를 위해 렌더링 중에 계산할 정보가 이미 충분하기 때문에 useState가 필요하지 않습니다. 

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];
  // ...

 다음으로, Game 컴포넌트 내에 handlePlay 함수를 생성하십시오. 이 함수는 Board 컴포넌트에서 호출되어 게임을 업데이트합니다. xIsNext, currentSquares 및 handlePlay를 props로 Board 컴포넌트에 전달하십시오.

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    // TODO
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
        //...
  )
}

 Board 컴포넌트를 완전히 props에 의해 제어되도록 만들어 보겠습니다. Board 컴포넌트를 변경하여 세 가지 props를 받도록 합니다. xIsNext, squares 및 새로운 onPlay 함수입니다. 이 함수는 플레이어가 이동을 수행할 때 Board가 호출할 수 있도록 업데이트된 squares 배열을 전달해야 합니다. 그 다음, useState를 호출하는 첫 두 줄을 제거하세요.

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    //...
  }
  // ...
}

 이제 Board 컴포넌트의 handleClick에서 setSquares 및 setXIsNext 호출을 새로운 onPlay 함수로 대체하여 사용자가 사각형을 클릭할 때 Game 컴포넌트가 Board를 업데이트할 수 있도록합니다.

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    onPlay(nextSquares);
  }
  //...
}

 Board 컴포넌트는 Game 컴포넌트에서 전달된 props에 의해 완전히 제어됩니다. 게임을 다시 작동하려면 게임 컴포넌트에서 handlePlay 함수를 구현해야 합니다. handlePlay함수가 호출될때 무엇을 해야 할까요? Board가 이제 업데이트된 배열을 onPlay에 전달하는 것으로 바뀌었습니다. handlePlay 함수는 게임의 상태를 업데이트하여 다시 렌더링을 트리거해야 합니다. 이제 더 이상 호출할 setSquares 함수가 없으므로 이제는 이 정보를 저장하기 위해 history 상태 변수를 사용합니다. 업데이트된 squares 배열을 새로운 history 항목으로 추가하여 history를 업데이트해야 합니다. 또한 Board가 수행했던 것처럼 xIsNext를 토글해야 합니다.

export default function Game() {
  //...
  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }
  //...
}

여기서 [...history, nextSquares]는 history에 있는 모든 항목 뒤에 nextSquares가 오는 새 배열을 생성합니다. (...history 펼침 구문을 "history에 있는 모든 항목을 나열하라"로 읽을 수 있습니다.)

 

4.3 이전 수들 보여주기

 틱택토 게임의 이력을 기록하고 있으므로 이제 플레이어에게 지난 수 목록을 표시할 수 있습니다. <button>과 같은 React의 요소들은 일반적인 JavaScript 객체입니다. React에서 여러 항목을 렌더링하려면 React 요소의 배열을 사용할 수 있습니다. 이미 상태에 이동 이력의 배열이 있으므로 이제 이를 React 요소의 배열로 변환해야 합니다. JavaScript에서는 배열 map 메서드를 사용하여 하나의 배열을 다른 배열로 변환할 수 있습니다.

 Game 컴포넌트에서 이동 이력을 React 요소로 나타내는 버튼으로 변환하기 위해 map을 사용하고, 이전 수로 "이동"할 수 있는 버튼 목록을 표시할 것입니다. 이제 Game 컴포넌트에서 이력을 매핑하겠습니다.

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  function jumpTo(nextMove) {
    // TODO
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = 'Go to move #' + move;
    } else {
      description = 'Go to game start';
    }
    return (
      <li>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

 이렇게 된다면 Warning: Each child in an array or iterator should have a unique “key” prop. Check the render method of `Game`. 이라는 오류가 표시됩니다. 다음 섹션에서 이 오류를 고칠예정입니다.

 

4.4 키 이해하기

리스트를 렌더링할 때 React는 각 렌더링된 리스트 아이템에 대한 정보를 저장합니다. 리스트를 업데이트할 때 React는 변경된 사항을 판별해야 합니다. 그래야만 리스트의 아이템을 추가, 제거, 재배열 또는 업데이트할 수 있습니다.

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>
// to
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

 변경된 카운트 외에, 사람이 이를 읽을 때는 아마도 Alexa와 Ben의 순서를 바꾸고, Alexa와 Ben 사이에 Claudia를 삽입했다고 할 것입니다. 그러나 React는 컴퓨터 프로그램이므로 의도한 대로 작동하기 위해 각 리스트 항목에 대한 키 속성을 지정해야 합니다. 데이터가 데이터베이스에서 가져온 것이라면, Alexa, Ben 및 Claudia의 데이터베이스 ID를 각 리스트 항목의 키로 사용할 수 있습니다.

<li key={user.id}>
  {user.name}: {user.taskCount} tasks left
</li>
 

 리스트가 다시 렌더링될 때, React는 각 리스트 항목의 키를 가져와 이전 리스트의 항목에서 해당 키와 일치하는 것을 찾습니다. 현재 리스트에 이전에 없던 키가 있는 경우, React는 컴포넌트를 생성합니다. 현재 리스트에 이전 리스트에 있던 키가 누락된 경우, React는 이전 컴포넌트를 파괴합니다. 두 키가 일치하는 경우, 해당 컴포넌트가 이동됩니다.

 key는 React에서 특별하고 예약된 속성입니다. 요소가 생성될 때, React는 key 속성을 추출하여 반환된 요소에 직접 저장합니다. key가 props로 전달된 것처럼 보이지만, React는 자동으로 key를 사용하여 어떤 컴포넌트를 업데이트할지 결정합니다. 컴포넌트가 부모가 지정한 키를 물어볼 수 있는 방법은 없습니다. 동적 리스트를 작성할 때 적절한 키를 할당하는 것이 강력히 권장됩니다. 적절한 키가 없는 경우, 데이터를 재구성하여 적절한 키를 가질 수 있도록 고려해야 합니다.

 만약 키가 지정되지 않았다면, React는 오류를 보고하고 기본적으로 배열 인덱스를 키로 사용합니다. 배열 인덱스를 키로 사용하는 것은 리스트 항목을 재배열하거나 리스트 항목을 삽입/제거할 때 문제가 발생할 수 있습니다. 명시적으로 key={i}를 전달하면 오류가 해결되지만, 배열 인덱스와 같은 문제가 발생하며 대부분의 경우 권장되지 않습니다.키는 전역적으로 고유할 필요가 없습니다. 컴포넌트와 해당 형제 간에만 고유하면 됩니다.

 

4.5 시간여행 삽입하기

틱택토 게임의 히스토리에서 각 이전 수는 해당하는 고유한 ID를 가지고 있습니다. 이것은 수의 연속된 번호입니다. 수는 결코 재정렬되거나 삭제되거나 중간에 삽입되지 않으므로 인덱스를 키로 사용하는 것이 안전합니다. Game 함수에서 키를 다음과 같이 추가할 수 있습니다: <li key={move}>. 그리고 렌더링된 게임을 다시로드하면 React의 "key" 오류가 사라져야 합니다.

const moves = history.map((squares, move) => {
  //...
  return (
    <li key={move}>
      <button onClick={() => jumpTo(move)}>{description}</button>
    </li>
  );
});
 jumpTo를 구현하기 전에 사용자가 현재 보는 단계를 추적하도록 Game 컴포넌트를 준비해야 합니다. 이를 위해 기본값이 0인 currentMove라는 새로운 상태 변수를 정의하십시오.
export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[history.length - 1];
  //...
}
 

 다음으로, Game 내부의 jumpTo 함수를 업데이트하여 currentMove를 업데이트해야 합니다. 또한, currentMove를 변경하는 숫자가 짝수일 경우 xIsNext를 true로 설정해야 합니다.

export default function Game() {
  // ...
  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
  }
  //...
}

 

이제 클릭한 square에 대한 handlePlay 함수는 Game에 두 가지 변경점를 만들 것입니다.
  1. "과거로 돌아가"서 그 이후 새로운 수를 만든 경우, 해당 지점까지의 히스토리만 유지하려고 합니다. 따라서 history의 모든 항목 (... spread syntax) 이후가 아니라 history.slice(0, currentMove + 1) 이후에 nextSquares를 추가합니다. 이렇게 하면 이전 히스토리의 해당 부분만 유지됩니다.
  2. 움직임이 만들어질 때마다, currentMove를 가장 최신의 히스토리 항목을 가리키도록 업데이트해야 합니다.
function handlePlay(nextSquares) {
  const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
  setHistory(nextHistory);
  setCurrentMove(nextHistory.length - 1);
  setXIsNext(!xIsNext);
}

 

마지막으로, Game 컴포넌트를 수정하여 항상 최종 움직임을 렌더링하는 대신 현재 선택된 움직임을 렌더링하도록 변경할 것입니다.
export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[currentMove];

  // ...
}

 게임의 히스토리에서 어떤 단계를 클릭하면, 틱택토 보드는 즉시 해당 단계가 발생한 후 보드가 어떻게 보였는지 업데이트되어야 합니다.

4.6 마무리 작업

코드를 매우 주의 깊게 살펴보면, currentMove가 짝수일 때 xIsNext === true이고, currentMove가 홀수일 때 xIsNext === false임을 알 수 있습니다. 즉, currentMove의 값을 알고 있다면 언제나 xIsNext가 어떻게 되어야 하는지 알 수 있습니다. 두 가지를 모두 상태에 저장할 필요가 없습니다. 사실, 중복된 상태를 피하는 것이 좋습니다. 상태에 저장하는 내용을 단순화하면 버그가 줄어들고 코드를 이해하기 쉬워집니다. 따라서 Game이 xIsNext를 별도의 상태 변수로 저장하지 않고 현재의 움직임을 기반으로 계산하도록 변경하세요.

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }
  // ...
}

 이제 xIsNext 상태 선언이나 setXIsNext 호출이 더이상 필요하지 않습니다. 이제는 컴포넌트를 코딩하는 동안 실수를 해도 xIsNext가 currentMove와 동기화되지 않을 가능성이 없습니다.

4.7 끝마치며

축하합니다! 여러분은 틱택토 게임을 만들었습니다. 이 게임은 다음과 같은 기능을 제공합니다:

  • 틱택토를 플레이할 수 있습니다.
  • 플레이어가 게임에서 이겼을 때를 알려줍니다.
  • 게임이 진행됨에 따라 게임의 히스토리를 저장합니다.
  • 플레이어가 게임의 히스토리를 검토하고 이전 버전의 게임 보드를 볼 수 있습니다.

잘 하셨습니다! 이제 여러분은 React가 어떻게 작동하는지에 대해 꽤 괜찮은 이해를 가지고 있다고 느낄 것입니다. 여유 시간이 있거나 새로운 React 기술을 연습하고 싶다면, 틱택토 게임을 개선할 수 있는 몇 가지 아이디어가 있습니다. 이는 난이도가 증가하는 순서대로 나열되어 있습니다:

  1. 현재 움직임만 표시하는 대신 버튼 대신 "당신은 #번째 움직임에 있습니다..."을 표시합니다.
  2. 제곱형을 하드코딩하는 대신 두 개의 루프를 사용하여 보드를 다시 작성합니다.
  3. 오름차순 또는 내림차순으로 움직임을 정렬할 수 있는 토글 버튼을 추가합니다.
  4. 누군가가 이기면 이긴 세 개의 제곱형을 강조 표시합니다(누가 이기지 않으면 결과가 무승부임을 메시지로 표시합니다).
  5. 이동 히스토리 목록에 각 움직임의 위치를 (행, 열) 형식으로 표시합니다.

 이 튜토리얼을 통해 요소(elements), 컴포넌트(components), 프롭스(props), 상태(state)와 같은 React 개념에 대해 다루었습니다. 이러한 개념이 게임을 만들 때 어떻게 작동하는지 보았으므로, Thinking in React를 확인하여 같은 React 개념이 앱의 UI를 구축할 때 어떻게 작동하는지 살펴보세요.

 

 

 
본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

 튜토리얼을 통해 간단한 Tic-Tac-Toe 게임을 구현합니다. 기존의 React 지식을 요구하지 않으며 구현을 통해 React 구축에 기본이 되는 개념들을 이해할 수 있습니다. 튜토리얼은 4가지 섹션으로 나누어져 있습니다.

  1. 튜토리얼 설정은 튜토리얼을 따라가기 위한 시작점을 제공합니다.
  2. 구현하기에서는 React의 기본요소인 컴포넌트, props, state에 대해 공부합니다.
  3. 게임 완성에서는 React 개발에 대해 가장 기본적인 기술을 공부합니다.
  4. 시간여행 추가에서는 React의 고유한 강점에 대해 더욱 깊게 알아봅니다.

앞선 1,2 섹션은 이전 글을 참고하시길 바랍니다.

3. 게임 완료하기

 이제 tic-tac-toe 게임을 위한 모든 기본 구성 요소가 완성되었습니다. 완전한 게임을 위해서는 이제 보드에 "X"와 "O"를 번갈아 배치해야 하며 승자를 결정하는 방법이 필요합니다.

 

3.1 상태 끌어올리기

현재 각 Square 컴포넌트는 게임의 일부 상태를 유지합니다. 틱택톡 게임에서 승자를 확인하려면 Board가 9개의 Square 컴포넌트 각각의 상태를 알아야합니다. 어떻게 접근하면 좋을까요? 먼저, Board가 각 Square에게 해당 상태를 Request해야한다고 생각할 수 있습니다. 이 접근방식은 React에서 기술적을 가능하지만 코드가 이해하기 어려워지고 버그발생과 리팩토링를 하기 어려워지기에 권장하지 않습니다. 최선의 방법은 각 Square가 아닌 부모 Board 컴포넌트에게 게임 상태를 저장하는 것입니다. Board 컴포넌트는 각 Square에게 숫자를 전달했을 떄와 같이 props를 통해 어떤 것을 표시할지 알려줄 수 있습니다.

 여러 하위 컴포넌트에서 데이터를 수집하거나 두 하위 컴포넌트가 서로 통신하도록 하려면 대신 상위 컴포넌트에서 공유 상태를 선언하세요. 상위 컴포넌트는 props를 통해 해당 상태를 하위 컴포넌트로 다시 전달할 수 있습니다. 이렇게 하면 하위 컴포넌트가 서로 및 해당 상위 컴포넌트와 동기화됩니다. 9개의 사각형에 해당하는 9개의 null 배열을 기본으로 하는 상태 변수를 선언하도록  Board 컴포넌트를 편집합니다.

// ...
export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
  return (
    // ...
  );
}

 

Array(9).fill(null)은 아홉 개의 요소가 있는 배열을 생성하고 각각을 null로 설정합니다, 그 주위의 useState() 호출은 초기 값으로 해당 배열이 설정된 squares 상태 변수를 선언합니다. 배열의 각 항목은 사각형의 값에 해당합니다. 이후 Board컴포넌트에서 렌더링하는 각 Square 항목에 value props 을 전달합니다.

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} />
        <Square value={squares[1]} />
        <Square value={squares[2]} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} />
        <Square value={squares[4]} />
        <Square value={squares[5]} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} />
        <Square value={squares[7]} />
        <Square value={squares[8]} />
      </div>
    </>
  );
}

다음으로, Square 컴포넌트를 편집하여 Board 컴포넌트에서 value prop을 받도록합니다. 이를 위해 Square 컴포넌트의 자체 상태 추적 및 버튼의 onClick prop을 제거해야합니다.

 

function Square({value}) {
  return <button className="square">{value}</button>;
}

이제 각 Square는 'X', 'O' 또는 빈 사각형에 대한 null이 될 value prop을 받게됩니다. 다음으로, Square가 클릭될 때 발생하는 일을 변경해야합니다. Board 컴포넌트는 이제 어떤 사각형이 채워졌는지 유지합니다. Square가 Board의 상태를 업데이트하는 방법을 만들어야합니다. 상태는 정의한 컴포넌트에 대해 비공개이므로 Square에서 Board의 상태를 직접 업데이트할 수 없습니다.

대신에, Board 컴포넌트에서 Square 컴포넌트로 함수를 전달하고, Square가 클릭되었을 때 해당 함수를 호출하도록 만들 것입니다. Square 컴포넌트가 클릭될 때 호출 할 함수로 시작합니다. 해당 함수를 onSquareClick으로 호출할 것입니다.

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

이제 onSquareClick prop을 Board 컴포넌트의 handleClick이라는 함수에 연결할 것입니다. onSquareClick을 handleClick에 연결하기 위해 첫 번째 Square 컴포넌트의 onSquareClick prop에 함수를 전달합니다

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={handleClick} />
        //...
  );
}

 

마지막으로, Board 컴포넌트 내에서 handleClick 함수를 정의하여 보드의 상태를 저장하는 squares 배열을 업데이트할 것입니다.

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick() {
    const nextSquares = squares.slice();
    nextSquares[0] = "X";
    setSquares(nextSquares);
  }

  return (
    // ...
  )
}

 handleClick 함수는 JavaScript의 slice() 배열 메서드를 사용하여 squares 배열의 사본 (nextSquares)을 생성합니다. 그런 다음,handleClick은 nextSquares 배열을 업데이트하여 첫 번째 ([0] 인덱스) 사각형에 X를 추가합니다.setSquares 함수를 호출하면 React에게 컴포넌트의 상태가 변경되었음을 알립니다. 이로 인해 squares 상태를 사용하는 컴포넌트 (Board) 및 해당 하위 컴포넌트 (보드를 구성하는 Square 컴포넌트)의 다시 렌더링이 트리거됩니다.

  • JavaScript는 클로저를 지원합니다. 이는 내부 함수 (예: handleClick)가 외부 함수 (예: Board)에 정의된 변수와 함수에 접근할 수 있음을 의미합니다. handleClick 함수는 squares 상태를 읽고 setSquares 메서드를 호출할 수 있습니다. 이는 두 함수 모두 Board 함수 내에서 정의되었기 때문입니다.

handleClick가 어떤 사각형이든 업데이트할 수 있도록 업데이트 할 사각형의 인덱스를 취하는 인수 i를 추가합니다.

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    const nextSquares = squares.slice();
    nextSquares[i] = "X";
    setSquares(nextSquares);
  }

  return (
    // ...
  )
}

 

다음으로, 그 i를 handleClick에 전달해야합니다. JSX에서 onSquareClick prop을 handleClick(0)으로 직접 설정해보실 수 있지만 다음과 같이 하면 작동하지 않습니다.

<Square value={squares[0]} onSquareClick={handleClick(0)} />

이렇게 하면 작동하지 않는 이유는 handleClick(0) 호출이 보드 컴포넌트를 렌더링하는 일부가 될 것이기 때문입니다. handleClick(0)은 setSquares를 호출하여 보드 컴포넌트의 상태를 변경합니다. 따라서 전체 보드 컴포넌트가 다시 렌더링됩니다. 하지만 이는 handleClick(0)을 다시 실행하여 무한 루프에 이르게합니다.

 

이 문제가 이전에 발생하지 않은 이유는 onSquareClick={handleClick}를 전달할 때 handleClick 함수를 prop으로 전달했기 때문입니다. 이는 실제로 함수를 호출하는 것이 아니였습니다. 그러나방금의 코드는 함수를 바로 호출하고 있습니다. 즉, handleClick(0)에서 괄호를 볼 수 있습니다. 이 문제를 해결하려면 handleClick(0)을 호출하는 handleFirstSquareClick과 같은 함수를 만들거나 handleSecondSquareClick과 같은 함수를 만들어야합니다. 그런 다음 onSquareClick={handleFirstSquareClick}과 같은 형태로 이러한 함수를 prop으로 전달합니다. 이렇게하면 무한 루프가 해결됩니다.

그러나 아홉 개의 서로 다른 함수를 정의하고 각각에 이름을 지정하는 것은 너무 장황합니다. 대신 화살표 함수를 사용해 봅시다.

export default function Board() {
  // ...
  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        // ...
  );
}

 이제 상태 처리가 Board 컴포넌트에 있으므로 부모 Board 컴포넌트는 자식 Square 컴포넌트에게 올바르게 표시될 수 있도록 props를 전달합니다. 사각형을 클릭하면 자식 Square 컴포넌트가 상태를 업데이트하기 위해 부모 Board 컴포넌트에게 요청합니다. Board의 상태가 변경되면 Board 컴포넌트와 모든 자식 Square가 자동으로 다시 렌더링됩니다. Board 컴포넌트에 모든 사각형의 상태를 유지하면서 나중에 승자를 결정할 수 있습니다. 사용자가 보드의 왼쪽 상단 사각형을 클릭하여 X를 추가하는 과정을 요약해보겠습니다.

  1. 상단 왼쪽 사각형을 클릭하면 버튼이 onClick prop으로 전달 받은 함수가 실행됩니다. Square 컴포넌트는 이 함수를 Board로부터 onSquareClick을 prop으로 전달 받았습니다. Board 컴포넌트는 이 함수를 JSX에서 직접 정의했습니다. 이 함수는 handleClick을 0 인수와 함께 호출합니다.
  2. handleClick은 인수(0)를 사용하여 squares 배열의 첫 번째 요소를 null에서 X로 업데이트합니다.
  3. Board 컴포넌트의 squares 상태가 업데이트되었으므로 Board와 모든 자식이 다시 렌더링됩니다. 이로 인해 인덱스 0의 Square 컴포넌트의 value prop이 null에서 X로 변경됩니다.
  4. 사용자는 상단 왼쪽 사각형을 클릭한 후 빈 상태에서 X로 변경된 것을 확인할 수 있습니다.
  • DOM의 <button> 요소의 onClick 속성은 내장 컴포넌트이기 때문에 React에서 특별한 의미를 갖습니다. Square와 같은 사용자 지정 컴포넌트의 경우, 이름 지정은 사용자 마음입니다. Square의 onSquareClick prop이나 Board의 handleClick 함수에 아무 이름을 부여해도 코드가 동일하게 작동합니다. React에서는 이벤트를 나타내는 props에 대해 onSomething 이름을 사용하고 해당 이벤트를 처리하는 함수 정의에 대해서는 handleSomething을 사용하는 것이 관례입니다.

3.2 불변성의 중요성

 handleClick에서 기존 배열을 수정하는 대신 .slice()를 호출하여 squares 배열의 사본을 생성하는 방법에 주목하세요. 이를 설명하기 위해서는 불변성과 불변성이 왜 중요한지에 대해 논의해야합니다. 데이터를 변경하는 데 일반적으로 두 가지 접근 방법이 있습니다. 첫 번째 접근 방법은 데이터의 값을 직접 변경하여 데이터를 변경하는 것입니다. 두 번째 접근 방법은 원하는 변경 사항이 있는 새로운 사본으로 데이터를 교체하는 것입니다. 결과는 동일하지만 직접 데이터를 변형하지 않는다면 여러 가지 이점을 얻을 수 있습니다.

const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
setSquare(nextSquares);
  1. 복잡한 기능을 훨씬 쉽게 구현할 수 있습니다. 튜토리얼 후반부터는 게임의 이력을 검토하고 과거로 돌아가는 "타임 트래블" 기능을 구현합니다. 이는 게임에만 특정한 것이 아닙니다. 특정 작업을 실행 취소하고 다시 실행하는 기능은 앱에서 일반적으로 요구되는 기능입니다. 직접 데이터 변형을 피함으로써 이전 데이터 버전을 그대로 유지하고 나중에 재사용할 수 있습니다.
  2. 컴포넌트가 데이터가 변경되었는지 여부를 비교하는 것을 매우 쉽게 만듭니다. 기본적으로 부모 컴포넌트의 상태가 변경되면 모든 하위 컴포넌트가 자동으로 다시 렌더링됩니다. 이 변경 사항에 영향을받지 않은 하위 컴포넌트도 포함됩니다.불변성을 유지함으로써 성능을 최적화할 수 있습니다. 불변성은 데이터 변경 여부를 쉽게 비교할 수 있도록 해줍니다. 변경된 데이터와 이전 데이터를 비교하여 실제 변경된 부분만을 재렌더링할 수 있습니다.

3.3 차례나누기

 현재 게임은  "O"를 표시할 수 없습니다. 이를 수정하기 위해서 첫 번째 동작을 기본적으로 "X"로 설정할 것입니다. Board 컴포넌트에 다른 상태를 추가하여 이를 추적해 봅시다.

function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  // ...
}

각 플레이어가 클릭할 때마다, xIsNext(boolean 값)는 다음에 어떤 플레이어가 진행될지를 결정하기 위해 반전됩니다. Board의 handleClick 함수를 업데이트하여 xIsNext의 값을 반전시킬 것입니다.

export default function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  return (
    //...
  );
}

이제 다른 사각형을 클릭할 때마다 X와 O가 번갈아가며 표시됩니다. 그러나 문제가 있습니다. 동일한 사각형을 여러 번 클릭하면 이미 채워진 사각형 X가 O에 덮어씌워집니다. X 또는 O로 사각형을 표시할 때 사각형에 이미 X 또는 O 값이 있는지 확인하지 않고 있습니다. 이 문제를 해결하려면 먼저 조기 반환을 수행해야 합니다. handleClick 함수 내에서 보드 상태를 업데이트하기 전에 사각형에 이미 X 또는 O가 있는지 확인할 것입니다.

function handleClick(i) {
  if (squares[i]) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

 

3.4 승자 판별

 플레이어가 순서대로 수를 둘 수 있게 되었으므로, 게임이 이겼을 때와 더 이상 수를 둘 수 없는 경우를 표시하고 싶을 것입니다. 이를 위해 calculateWinner라는 도우미 함수를 추가할 것입니다. 이 함수는 9개의 사각형 배열을 가져와 승자를 확인하고 적절한 'X', 'O' 또는 null을 반환합니다. 

export default function Board() {
  //...
}

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}
  • calculateWinner 함수를 Board 컴포넌트 이전에 정의하든 이후에 정의하든 상관없습니다. 매번 컴포넌트를 편집할 때마다 스크롤을 내려야 하는 번거로움을 피하기 위해 끝에 위치시키겠습니다.

 calculateWinner(squares) 함수를 Board 컴포넌트의 handleClick 함수에서 호출하여 플레이어가 이겼는지 확인할 것입니다. 이미 X 또는 O가 있는 사각형을 클릭했는지 확인하는 동시에 이 확인을 수행할 수 있습니다. 우리는 두 경우 모두 조기 반환하는 것을 원합니다.

function handleClick(i) {
  if (squares[i] || calculateWinner(squares)) {
    return;
  }
  const nextSquares = squares.slice();
  //...
}

 게임이 종료된 경우 "Winner: X" 또는 "Winner: O"와 같은 텍스트를 플레이어에게 표시하여 알려줄 수 있습니다. 이를 위해 Board 컴포넌트에 상태 섹션을 추가할 것입니다. 상태는 게임이 종료된 경우 승자를 표시하고, 게임이 진행 중인 경우 다음 플레이어의 차례를 표시할 것입니다.

export default function Board() {
  // ...
  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = "Winner: " + winner;
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        // ...
  )
}

이로써 틱택톡 게임과 React의 기본 구현에 대해 배웠습니다.

본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

 튜토리얼을 통해 간단한 Tic-Tac-Toe 게임을 구현합니다. 기존의 React 지식을 요구하지 않으며 구현을 통해 React 구축에 기본이 되는 개념들을 이해할 수 있습니다. 튜토리얼은 4가지 섹션으로 나누어져 있습니다.

  1. 튜토리얼 설정은 튜토리얼을 따라가기 위한 시작점을 제공합니다.
  2. 구현하기에서는 React의 기본요소인 컴포넌트, props, state에 대해 공부합니다.
  3. 게임 완성에서는 React 개발에 대해 가장 기본적인 기술을 공부합니다.
  4. 시간여행 추가에서는 React의 고유한 강점에 대해 더욱 깊게 알아봅니다.

1. 튜토리얼 설정

 시작하기에 앞서 세 가지 주요 섹션이 있습니다.

  1. App.js, index.js, style.css 그리고 public 폴더
  2. 선택한 파일의 소스 코드를 볼 수 있는 코드 편집기
  3. 작성한 코드가 어떻게 표시되는지 확인할 수 있는 브라우저

1.1 App.js

export default function Square() {
	return <button className="Square">X</button>;
}

 App.js의 코드는 컴포넌트를 생성합니다. React에서 컴포넌트는 사용자 인터페이스의 일부를 나타내는 재사용 가능한 코드조각입니다. 컴포넌트는 애플리케이셔의 UI 요소를 렌더링, 관리 및 업데이트하는 데 사용됩니다. 첫 번째 줄은 Square라는 함수를 정의합니다. JavaScript의 export 키워드를 사용하면 이 파일 외부에서 함수를 사용할수 있습니다. default 키워드는 이파일의 메인기능이라는 것을 다른 파일에 알려주는 역할을 합니다. 두 번째 줄은 버튼을 반환합니다. JavaScript return 키워드는 뒤에 오는 모든 함목이 함수 호출자에게 값으로 반환됨을 의미합니다. 그다음은 JSX 요소<button> 입니다. JSX 요소는 표시하려는 내용을 설명하는 JavaScript 코드와 HTML 태그의 조합입니다. className="square"는 CSS에 버튼 스타일 지정을 알려주는 버튼의 속성 또는 prop 입니다. X는 버튼 내부에 표시되는 텍스트이며 </button>는 JSX 요소를 닫아 다음 콘텐츠가 버튼 내부에 배치되어서는 안 됨을 나타냅니다.

 

1.2 styles.css

 이 파일은 React 앱의 스타일을 정의합니다. 처음 두개의 CSS 선택자 (* 와 body)는 앱의 큰 부분의 스타일을 정의하는 반면 .square 선탁자는 className이 square로 설정된 모든 구성요소의 스타일을 정의합니다. 이것은 App.js 파일에서 Square 컴포넌트의 버튼과 일치할 것입니다.

 

1.3 index.js

 튜토리얼 중에는 이 파일을 편집하지 않을 것이지만 이는  App.js 파일에서 생성한 컴포넌트와 웹 브라우저 사이의 다리 역할을 합니다. 

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';

import App from './App';

일반적인 React 애플리케이션 파일의 1~5번째 줄은 모든 필수 구성요소와 종속성을 함께 가져옵니다 :

  1. React : React 라이브러리를 가져옵니다. 이는 React 애플리케이션을 구축하는 데 필수적입니다.
  2. React DOM : React DOM은 DOM(Document Object Model)과 상호 작용하기 위한 특정 메서드를 제공하는 별도의 패키지입니다. 이는 React 컴포넌트를 브라우저에 렌더링하는 데 사용됩니다.
  3. style : 애플리케이션의 컴포넌트에 필요한 CSS 또는 스타일 종속성을 포함합니다. 이는 컴포넌트가 디자인 사양에 따라 스타일이 지정될 수 있도록 합니다.
  4. App 컴포넌트 : 주로 App으로 명명된 애플리케이션의 주요 컴포넌트를 가져옵니다. 이 컴포넌트는 애플리케이션의 컴포넌트 계층 구조의 루트 역할을 합니다.

파일의 나머지 부분은 일반적으로 이러한 구성 요소를 모두 함께 결합하고 최종 결과물을 public 폴더의 index.html에 삽입합니다. React 애플리케이션은 일반적으로 root React 컴포넌트가 마운트되는 진입점 HTML 파일(일반적으로 index.html)을 가지고 있습니다.

 

2. 구현하기

2.1 보드 만들기

 현재 보드는 단 하나의 정사각형이지만 9개가 필요합니다. 정사각형을 복하여 붙여넣으면 다음과 같은 두 개의 정사각형을 만들 수 있습니다. 그러나 React 구성 요소는 두 개의 버튼과 같은 여러 개의 인접한 JSX 요소가 아닌 단일 JSX 요소를 반환해야 합니다. 이 문제를 해결하려면 조각 ( <>및 </>)을 사용하여 다음과 같이 여러 개의 인접한 JSX 요소를 래핑할 수 있습니다.

export default function Square() {
  return (
    <>
      <button className="square">X</button>
      <button className="square">X</button>
    </>
  );
}

 9개의 사각형을 붙여넣은 후 각 행마다 div로 그룹화 시켜줍니다. 이후 CSS 클래스를 추가하고 어디에 있는지를 나타내기 위해 숫자를 적어줍니다. 이제는 Square가 아닌 Board가 되었으니 이름을 변경시켜줍니다! 

export default function Board() {
  return (
    <>
      <div className="board-row">
        <button className="square">1</button>
        <button className="square">2</button>
        <button className="square">3</button>
      </div>
      <div className="board-row">
        <button className="square">4</button>
        <button className="square">5</button>
        <button className="square">6</button>
      </div>
      <div className="board-row">
        <button className="square">7</button>
        <button className="square">8</button>
        <button className="square">9</button>
      </div>
    </>
  );
}

 

2.2 props를 통한 데이터 전달

 다음으로, 사용자가 해당 사각형을 클릭할 때 사각형의 값을 빈 값에서 "X"로 변경하려고 합니다. 지금까지 보드를 구축한 방식으로는 각 사각형을 업데이트하는 코드를 아홉 번 복사하여 붙여넣어야 합니다. 대신에 복사 및 붙여넣기 대신에 React의 컴포넌트 아키텍처를 사용하여 코드를 깔끔하게하고 중복을 피할 수있는 재사용 가능한 컴포넌트를 만들 수 있습니다.

먼저, Board 컴포넌트에서 첫 번째 사각형을 정의하는 줄(<button className="square">1</button>)을 가져와서 새로운 Square 컴포넌트를 만들어야합니다.

function Square() {
  return <button className="square">1</button>;
}

export default function Board() {
  // ...
}

 그런 다음 JSX구문을 사용하여 Square 컴포넌트를 렌더링하도록 Board 컴포넌트를 수정합니다.

// ...
export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

 이제 각 사각형에는 "1"이 표시됩니다. 이 문제를 해결하려면 props를 사용하여 각 사각형이 상위 구성 요소(Board)에서 하위 구성 요소(Square)로 가져야 하는 값을 전달합니다. Square 컴포넌트가  Board에서 보내는 value prop를 읽을 수 있도록 수정합니다. JSX에서 "Escape JavaScript"하려면 중괄호가 필요합니다. 다음과 같이 JSX에 중괄호를 추가하세요.

function Square({ value }) {
  return <button className="square">{value}</button>;
}

 

지금은 빈 보드가 표시됩니다. 이는 Board 컴포넌트가 아직 렌더링하는 각 컴포넌트에 value prop를 전달하지 않았기때문 입니다. 각Square 컴포넌트에 렌더링할 value prop를 더해줍니다.

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square value="1" />
        <Square value="2" />
        <Square value="3" />
      </div>
      <div className="board-row">
        <Square value="4" />
        <Square value="5" />
        <Square value="6" />
      </div>
      <div className="board-row">
        <Square value="7" />
        <Square value="8" />
        <Square value="9" />
      </div>
    </>
  );
}

 

2.3 상호작용 컴포넌트 만들기

Square 컴포넌트를 클릭하면 x로 채워지게 해보겠습니다. Square 안에 handleClick이라는 함수를 선언해줍니다.그런 다음, Square에서 반환된 button JSX 요소의 props에 onClick을 추가합니다. 

function Square({ value }) {
  function handleClick() {
    console.log('clicked!');
  }

  return (
    <button
      className="square"
      onClick={handleClick}
    >
      {value}
    </button>
  );
}

 이제 square를 클릭하면 콘솔로그탭에서 clicked! 라고 띄워지는 것을 확인할 수 있습니다. 다음 단계에서는 Square 컴포넌트가 클릭되었음을 "기억"하고 "X"표시로 채우도록 합니다. 사물을 "기억"하기위해 컴포넌트는 상태state를 사용합니다. React은 컴포넌트에서 호출하여 항목을 "기억"할 수 있는 특수 함수 useState를 제공합니다.  Square의 현재 값(value)을 상태(state)에 저장하고, Square가 클릭 될 때 값(value)을 변경해 봅시다.

파일의 맨 위에 useState를 import합니다. 그리고 Square 컴포넌트에서 value prop을 제거합니다. 대신, Square 시작 부분에 useState를 호출하는 새로운 줄을 추가합니다. 이것은 value라는 상태 변수(state variable)를 반환해야합니다.

import { useState } from 'react';

function Square() {
  const [value, setValue] = useState(null);

  function handleClick() {
    //...

value 는 값을 저장하고 setValue는 값을 변경하는 데 사용할 수 있는 함수입니다. useState에 전달된 null은 상태 변수의 초기 값으로 사용되므로 여기서 처음에 value는 null과 같습니다. Square 컴포넌트가 더이상 props를 받지 않기에 value prop를 제거합니다.

// ...
export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

이제 Square를 클릭하면 "X"가 표시되오록 변경합니다. 이벤트 핸들러의 console.log("clicked!")를 setValue('X')로 바꿔줍니다.

function Square() {
  const [value, setValue] = useState(null);

  function handleClick() {
    setValue('X');
  }

  return (
    <button
      className="square"
      onClick={handleClick}
    >
      {value}
    </button>
  );
}

 클릭 핸들러(onClick 핸들러)에서 이 set 함수를 호출함으로써 React에게 해당 <button>이 클릭될 때마다 해당 Square를 다시 렌더링하도록 알립니다. 업데이트 후에는 Square의 값이 'X'가 되므로 게임 보드에 "X"가 표시됩니다. 아무 Square에나 클릭하면 "X"가 나타날 것입니다.각 Square는 고유한 상태를 가지고 있습니다: 각 Square에 저장된 값은 완전히 독립적입니다. 컴포넌트에서 set 함수를 호출하면 React가 자동으로 내부의 하위 컴포넌트도 업데이트합니다.

본 글은 React 공식문서를 정리 한 글입니다. 
더 자세한 내용은 https://react.dev/을 참고하시길 바랍니다.

 

빠른 시작에서는 React의 개념 중 80퍼센트를 대략적으로 설명한다.

  • How to create and nest components (컴포넌트 생성과 중첩)
  • How to add markup and styles (마크업,스타일 추가)
  • How to display data (데이터 표시)
  • How to render conditions and lists (조건과 목록 랜더링)
  • How to respond to events and update the screen (이벤트 응답과 화면 업데이트)
  • How to share data between components (컴포넌트 간 데이터 공유)

1. 컴포넌트 생성과 중첩

React는 컴포넌트를 기초로 만들어진다. 컴포넌트는 로직과 외형을 갖는 UI의 일부이며 버튼처럼 작을수도, 전체 페이지만큼 클 수도 있다.

React 컴포넌트는 마크업을 반환하는 JavaScript이다.

function MyButton() {
  return (
    <button>I'm a button</button>
  );
}

 

Mybutton 이라는 컴포넌트를 선언한 이후 다른 컴포넌트에 중첩(삽입)할 수 있다.

export default function MyApp() {
  return (
    <div>
      <h1>Welcome to my app</h1>
      <MyButton />
    </div>
  );

 

React 컴포넌트의 이름은 항상 대문자로 시작해야하며 HTML 태그는 소문자여야 한다. 이를 통해 컴포넌트를 구별 가능하다.

2. JSX로 마크업 작성

위에서 쓰여진 구문을 JSX라고 한다. 선택사항이지만 대부분의 React 프로젝트에서는 편의를 위해 JSX를 사용한다. 로컬개발에 권장되는 모든 도구는 기본적으로 JSX를 지원합니다.

 

JSX는 HTML보다 엄격하다. <br>로 쓰던 태그도 <br /> 와 같이 태그를 닫아야 한다. 또한 컴포넌트는 여러 JSX 태그를 반환할 수 없다. 그렇기에 <div> ... </div> 나 <> ... </> 와 같은 빈 태그로 하위 태그들을 공유된 상위부모 태그안에 감싸야한다.

function AboutPage() {
  return (
    <>
      <h1>About</h1>
      <p>Hello there.<br />How do you do?</p>
    </>
  );
}

 

JSX로 포트할 HTML가 많을 시 온라인 컨번터를 사용할 수 있다.

3. 스타일 추가

React에서는 className으로 스타일을 추가할 수 있다. 이는 HTML의 class 속성과 동일한 방식으로 작동한다.

<img className="avatar" />

 

이미지 태그에 className를 추가 한 다음 별도의 css파일에 css 규칙을 작성한다.

/* In your CSS */
.avatar {
  border-radius: 50%;
}

 

React은 css 파일을 추가하는 방법을 규정하지 않는다. 가장 간단한 경우 <link>태그를 통해 가능하다. 빌드 툴이나 프레임워크를 사용하는 경우 해당 사용서를 참조하여 프로젝트에 css 파일을 추가해야한다.

3. 데이터 표시

JSX를 사용하면 JavaScript에 마크업을 넣을 수 있습니다. 중괄호를 사용해 JavaScript로 “escape back” 하여 코드의 일부 변수를 포함하고 사용자에게 표시할 수 있습니다.

return (
  <h1>
    {user.name}
  </h1>
);

 

또한 JSX 속성에서 “escape into JavaScript” 할 수도 있지만 따옴표 대신 중괄호를 사용해야 합니다.

예를 들어 className= "avatar" 는 "avatar"를  css 클래스인 문자열로 전달한다. 하지만 src={user.imageUrl} 는 user.imageUrl 의 JavaScript 변수 값을 읽은 다음 해당 값을 속성으로 전달한다.

return (
  <img
    className="avatar"
    src={user.imageUrl}
  />
);

 

JSX 중괄호 안에 문자열 연결과 같은 더 복잡한 표현식을 넣을 수도 있다.

const user = {
  name: 'Hedy Lamarr',
  imageUrl: 'https://i.imgur.com/yXOvdOSs.jpg',
  imageSize: 90,
};

export default function Profile() {
  return (
    <>
      <h1>{user.name}</h1>
      <img
        className="avatar"
        src={user.imageUrl}
        alt={'Photo of ' + user.name}
        style={{
          width: user.imageSize,
          height: user.imageSize
        }}
      />
    </>
  );
}

 

위의 예시중, style={{}} 태그는 특별한 문법이 아니라 JSX 중괄호 안의 일반 객체이다. 스타일이 JavaScript 변수에 따라 달라지는 경우 style 속성을 사용할 수 있다.

4.  조건부 렌더링

React에는 조건 작성을 위한 특별한 구문이 없다. 대신 일반 JavaScript 코드를 작성할 때  사용하는 기술과 동일하게 사용하게 된다. 예를 들어 IF 조건부 구문을 JSX 안에 사용할 수 있다.

let content;
if (isLoggedIn) {
  content = <AdminPanel />;
} else {
  content = <LoginForm />;
}
return (
  <div>
    {content}
  </div>
);

 

보다 간결한 코드를 선호 하는 경우 ? 연산자를 사용할 수 있다. IF와 달리 JSX 내부에서 작동한다.

<div>
  {isLoggedIn ? (
    <AdminPanel />
  ) : (
    <LoginForm />
  )}
</div>

 

다른 분기가 필요하지 않은 경우 더 짧은 논리 구문인 && 를 사용할 수도 있다.

<div>
  {isLoggedIn && <AdminPanel />}
</div>

 

이러한 모든 접근 방법은 조건부로 속성을 지정하는 데에도 작동한다. 위 JavaScript 구문 중 일부에 익숙하지 않은 경우 언제든지 IF..else를 사용할 수 있다.

 

5.렌더링 목록

for 루프 및 배열 map()기능 과 같은 JavaScript 기능을 사용하여 구성 요소 목록을 렌더링한다. 예를 들어, 다음과 같은 제품 배열이 있다고 가정한다.

const products = [
  { title: 'Cabbage', id: 1 },
  { title: 'Garlic', id: 2 },
  { title: 'Apple', id: 3 },
];

 

컴포넌트 내에서 map() 함수를 사용하여 제품 배열을 <li> 항목 의 배열로 변환할 수 있다.

const listItems = product.map(product => 
	<li key={product.id}>
    	{product.title}
    <li/>
);

return (
  <ul>{listItems}</ul>
);

 

어떻게 <li> 태그가 key 속성을 가지는 지 주의해라. 목록 각 항목에 대해 동위 항목 중에서 해당 항목은 고유하게 식별하는 문자열이나 숫자를 전달해야 한다. 일반적으로 key는 데이터베이스 ID와 같은 데이터에서 가져와야 한다. React은 나중에 항목을 삽입, 삭제 또는 재정렬할 경우 어떤 일이 발생했는지 확인하기 위해 key를 사용한다.

const products = [
  { title: 'Cabbage', isFruit: false, id: 1 },
  { title: 'Garlic', isFruit: false, id: 2 },
  { title: 'Apple', isFruit: true, id: 3 },
];

export default function ShoppingList() {
  const listItems = products.map(product =>
    <li
      key={product.id}
      style={{
        color: product.isFruit ? 'magenta' : 'darkgreen'
      }}
    >
      {product.title}
    </li>
  );

  return (
    <ul>{listItems}</ul>
  );
}

6. 이벤트 응답

컴포넌트 내부에 이벤트 핸들러 함수를 선언하여 이벤트에 응답할 수 있다.

function MyButton() {
  function handleClick() {
    alert('You clicked me!');
  }

  return (
    <button onClick={handleClick}>
      Click me
    </button>
  );

onClick={hadleClick} 끝에 괄호가 없다는 점을 주의해라. 이벤트 핸들러 함수를 호출하지 마라 : 전달하기만 하면된다. 유저가 버튼을 누를 때 React가 이벤트 핸들러를 호출할 것이다. 

7. 화면 업데이트

종종 컴포넌트가 일부 정보를 "기억"하고 표시하도록 하려는 경우가 있다. 예를 들어 버튼을 클릭한 숫자를 계산하고 싶을 수도 있다. 이를 구형 하려면 컴포너트에 상태를 추가해야한다. 먼저 useState 를 가져온다.

import { useState } from 'react';

 

이제 컴포넌트 내에서 상태 변수를 선언할 수 있다.

function MyButton() {
  const [count, setCount] = useState(0);
  // ...

 

 

useState으로부터 두가지를 가질 수 있다 : 최신 상태 (count) 그리고 이를 업데이트할 수 있는 함수 (setCount). 이름은 무엇이든 지정할 수 있지만 관례로 [something, setSomething] 의 형식으로 쓴다.

 

처음 버튼이 표시될 때, count는 0 일 것이다. 왜냐하면 useState()를 통해 0를 전달하였기 때문이다. 상태를 변경하려면 setCount()를 호출하고 새로운 값을 전달하면 된다. 밑의 버튼을 클릭하면 카운터가 증가 할 것이다.

function MyButton() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      Clicked {count} times
    </button>
  );
}

 

React은 컴포넌트 함수를 다시 호출한다. 이때 count 는 1일 것이다. 그 후 2가 될 것이다.

만약 같은 컴포넌트를 여러개 렌더링 한다면 각각 고유한 상태를 갖는다. 그렇다면 각 버튼을 따로 클릭해야한다. 

import { useState } from 'react';

export default function MyApp() {
  return (
    <div>
      <h1>Counters that update separately</h1>
      <MyButton />
      <MyButton />
    </div>
  );
}

function MyButton() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      Clicked {count} times
    </button>
  );
}

 

각 버튼이 어떻게 고유 count 상태를 기억하는 지, 다른 버튼에 영향을 주지 않는지 주의하자.

8. Hooks 사용

use로 시작하는 이름의 함수를 Hooks라고 부른다. useState는 React이 제공하는 내장 Hooks이다. API reference에서 내장 Hooks에 대한 정보를 살펴볼 수 있다. 기존 Hooks을 결합하여 자신만의 Hooks을 작성할 수도 있다. 

Hooks은 다른 함수들 보다 제한적이다. 컴포넌트(또는 다른 Hook) 상단에서만 Hook를 호출할 수 있다. 만약 useState를 조건문이나 반복문에 사용하고 싶다면 다른 컴포넌트를 추출하여 넣어야한다. 

9. 컴포넌트 간 데이터 공유

이전 예에서 각각의 My button은 독립적인 count를 가지고 있었다. 그리고 각 버튼을 클릭할 때 클릭한 버튼에 대한 count만 변경되었다.

그러나 종종 컴포넌트 간의 데이터를 공유하고 같이 업데이트 해야 할 필요가 있다. 두 My button 컴포넌트를 모두 동일하게 표시하고 함께 업데이트 하려면 상태를 개별 버튼의 "위쪽", 각 버튼들이 포함된 가장 가까운 컴포넌트로 이동해야한다. 

이제 두 버튼 중 하나를 클릭하면 MyApp안의 count가 변경되어 MyButton 안의 두 count가 변경된다. 이를 코드로 구현하면 이와 같다.

먼저 상태를 Mybutton에서 MyApp으로 이동시킨다.

export default function MyApp() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <h1>Counters that update separately</h1>
      <MyButton />
      <MyButton />
    </div>
  );
}

function MyButton() {
  // ... we're moving code from here ...
}

 

이후 공유된 클릭핸들러와 상태를 함께 MyApp에서 각 Mybutton으로 보낸다. 이전에 <img>와 같은 내장 태그를 사용했던 거 처럼 JSX 중괄호를 사용하여 MyButton에 데이터를 전달할 수 있습니다.

export default function MyApp() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <h1>Counters that update together</h1>
      <MyButton count={count} onClick={handleClick} />
      <MyButton count={count} onClick={handleClick} />
    </div>
  );
}

 

이렇게 전달하는 정보를 props라고 한다. 이제 컴포넌트에는 count(상태)와 handlerClick(이벤트핸들러)가 MyApp에 포함되어 있으며 두 가지 모두 각 버튼의 props로 전달된다. 마지막으로 상위 컴포넌트에서 전달한 props를 MyButton이 읽도록 변경한다.

function MyButton({ count, onClick }) {
  return (
    <button onClick={onClick}>
      Clicked {count} times
    </button>
  );
}

 

버튼을 클릭하면 onClick 핸들러가 실행된다. 각 버튼의 onClick props은 MyApp 안의 handleClick 으로 설정되어있으므로 내부 코드가 작동된다. 해당 코드는 setCount(count + 1)를 호출하고 count 상태 값들을 증가시킨다. 새로운 count 값은 props로 각 버튼에 전달되고 유저들은 새로운 값을 볼 수있다. 이를 “lifting state up”라고 부른다. 상태를 위로 이동시켜 컴포넌트간의 데이터 공유를 할 수 있다.

import { useState } from 'react';

export default function MyApp() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <h1>Counters that update together</h1>
      <MyButton count={count} onClick={handleClick} />
      <MyButton count={count} onClick={handleClick} />
    </div>
  );
}

function MyButton({ count, onClick }) {
  return (
    <button onClick={onClick}>
      Clicked {count} times
    </button>
  );
}

 

+ Recent posts