본문 바로가기

나야, 리액트 스터디

[week5] 컴포넌트 간 state 공유

안녕하세요 웹 YB 김다현입니다.

거의 밤 새고 솝커톤 갔다가 약속까지 갔다오니,,, 너덜너덜해져서 결국 당일에 마감 직전에 아티클을 쓰게 됐네요 ㅠ,ㅠ

다들 바쁜 와중에도 열심히 공부하는 모습 보니 넘 머싯고 그렇타 ,,, !!!

아니 지금 보니까 저 오늘 발표네요 .......... 어쨌든 이번주차엔 input 다루기, state 구조 선택, 컴포넌트 간 state 공유, state 보존/초기화에 대해 공부해봤는데요. 저는 오늘 컴포넌트 간 state 공유에 대해 아티클을 써보려고 합니다!


🖇 state 끌어올리기란?

리액트를 쓰다 보면 두 컴포넌트가 같은 정보를 가지고 있어야 할 때가 있습니다.

예를 들어 한 컴포넌트에서 버튼을 클릭했을 때 다른 컴포넌트에도 변화가 생기기를 원한다고 해볼게요.

이럴 땐 두 컴포넌트 각각에 독립적인 상태를 두기보다는 공통 부모 컴포넌트로 상태를 옮기고 그 값을 props로 내려주는 게 좋아요!

이 방법을 바로 State 끌어올리기 라고 합니다.

 

👩‍💻 예제

한 번 예제를 살펴볼까요?! 아래는 부모 컴포넌트인 Accordion이 두 개의 자식 컴포넌트인 Panel을 렌더링하는 코드입니다.

 

Panel 컴포넌트는 어떻게 동작할까?

1️⃣ 각 패널은 버튼을 눌러야 내용을 보여준다

2️⃣ 버튼을 누르기 전까지는 닫힌 상태

3️⃣ 각각 독립적인 상태를 가지고 있어 한 패널의 버튼을 눌러도 다른 패널에는 영향을 주지 않음

 

import { useState } from 'react';

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>
          Show
        </button>
      )}
    </section>
  );
}

export default function Accordion() {
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel title="About">
        Almaty is Kazakhstan's largest city with a population of about 2 million.
      </Panel>
      <Panel title="Etymology">
        The name Almaty comes from the Kazakh word for "apple."
      </Panel>
    </>
  );
}

 

 

이 코드에서 각 Panel 컴포넌트는 독립적인 상태(isActive)를 가지고 있어요. 그래서 한 패널을 열어도 다른 패널은 영향을 받지 않습니다.

그렇다면! 두 번째 패널을 열기 위해 첫 번째 패널은 자동으로 닫히도록 만들고 싶다면 어떻게 해야 할까요? 🤔

➡️ State 끌어올리기를 통해 해결하기

이 문제를 해결하기 위해선 부모 컴포넌트가 모든 Panel의 상태를 관리하도록 만들어야 합니다.

 

1️⃣ 각 패널에서 상태를 없애고,
2️⃣ 부모 컴포넌트에 상태를 추가.
3️⃣ 부모에서 props와 이벤트 핸들러를 자식에게 내려주기

 

이렇게 하면 부모가 모든 패널의 상태를 조율할 수 있게 돼요. 한 번에 하나의 패널만 열리는 동작도 가능해지는 것!

 

Step 1. 자식 컴포넌트에서 state 제거하기

먼저 패널의 isActive 상태를 자식 컴포넌트에서 제거해야 합니다. 이제부터는 부모 컴포넌트가 이 상태를 직접 관리하도록 만들어볼 거예요!

 

const [isActive, setIsActive] = useState(false);​

 

위 코드를 자식 컴포넌트에서 제거합니다. 대신 isActive를 prop으로 받아 사용하도록 Panel의 코드를 수정해요.

function Panel({ title, children, isActive }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button>Show</button>
      )}
    </section>
  );
}

 

이렇게 Panel 코드를 수정하고 나면 이제 Panel 컴포넌트는 스스로 isActive를 관리하지 않아요. 대신 부모 컴포넌트로부터 내려받은 props를 사용합니다. 컨트롤은 부모가 하고 Panel은 그걸 보여주는 역할만 한다고 생각하면 돼요!

 

Step 2. 하드코딩된 데이터를 부모 컴포넌트로 전달하기

state를 올리려면 조정하려는 두 자식 컴포넌트의 가장 가까운 공통 부모 컴포넌트에 두어야 한다고 공식 문서에 나와있는데요.

현재 Panel의 가장 가까운 부모 컴포넌트는 Accordion 컴포넌트니까, 이 컴포넌트가 모든 상태를 관리해야 합니다.

즉, isActive의 값은 이제 부모 컴포넌트에서 props로 내려주게 되는 거겠죠!

 

export default function Accordion() {
  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel title="About" isActive={true}>
        With a population of about 2 million, Almaty is Kazakhstan's largest city.
      </Panel>
      <Panel title="Etymology" isActive={false}>
        The name comes from "alma," the Kazakh word for "apple."
      </Panel>
    </>
  );
}

 

현재 이 Accordion 컴포넌트에서 저 하드 코딩 되어있는 isActive 값을 변경해보면 잘 변경되는 걸 볼 수 있을 거예요.

 

Step 3. 공통 부모에 state 추가하기

이제 Accordion 컴포넌트가 모든 패널의 상태를 관리하도록 만들어볼 거예요. 이를 통해 한 번에 하나의 패널만 열리도록 동작하게 할 수 있습니다.

const [activeIndex, setActiveIndex] = useState(0);

 

Accordion 컴포넌트에 activeIndex라는 새로운 상태를 추가해요. 이 값은 현재 활성화된 패널의 인덱스를 저장합니다.

 

Accordion 컴포넌트 수정하기

import { useState } from 'react';

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0); // 상태 추가

  return (
    <>
      <h2>Almaty, Kazakhstan</h2>
      <Panel
        title="About"
        isActive={activeIndex === 0} // 첫 번째 패널이 열려 있는지 확인
        onShow={() => setActiveIndex(0)} // 클릭 시 첫 번째 패널을 활성화
      >
        With a population of about 2 million, Almaty is Kazakhstan's largest city. From 1929 to 1997, it was its capital city.
      </Panel>
      <Panel
        title="Etymology"
        isActive={activeIndex === 1} // 두 번째 패널이 열려 있는지 확인
        onShow={() => setActiveIndex(1)} // 클릭 시 두 번째 패널을 활성화
      >
        The name comes from "alma," the Kazakh word for "apple."
      </Panel>
    </>
  );
}

 

상태 변수

activeIndex -> 현재 열려 있는 패널의 인덱스를 저장합니다.

0: 첫 번째 패널이 열려 있음 / 1: 두 번째 패널이 열려 있음 / null: 모든 패널이 닫혀 있음

 

왜 인덱스를 사용하는 걸까?

Boolean 값 두 개(isActive를 각 패널에서 따로 관리) 대신 한 개의 숫자로 모든 패널의 상태를 관리하면 더 효율적이에요. 한 번에 하나의 패널만 열리도록 유일성을 강제할 수 있고 패널 개수가 많아져도 상태 관리 로직이 복잡해지지 않습니다.

 

Panel 컴포넌트 수정하기

function Panel({ title, children, isActive, onShow }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>Show</button>
      )}
    </section>
  );
}

 

onShow라는 이벤트 핸들러를 prop으로 받아서 버튼 클릭 시 실행하도록 수정합니다.

이제 버튼을 누르면 Accordion 컴포넌트의 activeIndex 상태가 변경되고 상태에 따라 패널이 열리고 닫히게 돼요!

 

 

 

동작 원리를 한 번 간단하게 정리해보자면!

 

1️⃣ 초기 상태
activeIndex는 0으로 초기화되었으므로 첫 번째 패널이 열려 있고 두 번째 패널은 닫혀 있다
이 값은 부모 컴포넌트(Accordion)가 패널에 props로 전달함

 

2️⃣ 버튼 클릭
패널의 버튼을 클릭하면 부모 컴포넌트의 setActiveIndex가 호출되어 activeIndex가 업데이트된다
예를 들어 두 번째 패널의 버튼을 클릭하면 setActiveIndex(1)이 실행되고 activeIndex는 1로 변경됨

 

3️⃣ 렌더링 결과
activeIndex 값에 따라 props로 내려가는 isActive 값이 업데이트된다

activeIndex === 0 -> 첫 번째 패널이 열리고 두 번째 패널은 닫힘

activeIndex === 1 -> 두 번째 패널이 열리고 첫 번째 패널은 닫힘

 

4️⃣ props로 이벤트 핸들러 전달
onShow라는 이름의 props로 클릭 이벤트 핸들러를 각 패널에 전달한다

패널이 클릭되면 이 핸들러가 부모의 상태를 업데이트한다

 

🎛 제어와 비제어 컴포넌트

제어와 비제어는 리액트에서 상태와 props를 어떻게 관리하느냐에 따라 나뉘는 개념인데요! 몇 번 들어보긴 했었지만 저도 이번 챕터를 공유하면서 개념을 처음 정리하게 됐답니다... 👣

 

비제어 컴포넌트란?

비제어 컴포넌트는 자체적으로 상태를 관리하는 컴포넌트입니다. 부모 컴포넌트에서 상태를 조작할 수 없고 컴포넌트 안에서만 상태가 결정돼요. 예를 들어 아까 봤던 Panel 컴포넌트를 떠올려볼까요?! Panel은 isActive라는 state를 가지고 스스로 열리고 닫히는 상태를 관리했었던 걸 알 수 있습니다.

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false); // 자체적으로 상태 관리
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>Show</button>
      )}
    </section>
  );
}

 

이 코드에서는 부모 컴포넌트가 Panel의 상태를 전혀 알 수도 제어할 수도 없어요.
Panel은 독립적으로 상태를 관리하기 때문에 비제어라고 부르는 겁니다!

 

제어 컴포넌트란?

제어 컴포넌트는 상태를 props로 관리하는 컴포넌트예요. 즉, 부모 컴포넌트가 자식 컴포넌트의 동작을 완전히 조정할 수 있습니다.

 

제어 컴포넌트의 핵심 💥

중요한 정보(ex. 열림/닫힘 상태)는 부모가 상태를 관리합니다.

자식 컴포넌트는 그 상태를 props로 전달받아 화면에 보여주는 역할만 합니다.

 

예를 들어 상태를 Accordion 컴포넌트에서 관리하게 리팩토링한 최종 Panel을 볼까요?

function Panel({ title, children, isActive, onShow }) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>Show</button>
      )}
    </section>
  );
}

 

여기서 중요한 점은 isActive 값과 버튼 클릭 시 상태를 변경하는 onShow 핸들러가 모두 부모 컴포넌트로부터 props로 전달된다는 점이에요.

 

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <>
      <Panel
        title="About"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      />
      <Panel
        title="Etymology"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      />
    </>
  );
}

 

부모 컴포넌트는 위와 같이 상태를 관리합니다. 이제 Panel 컴포넌트는 스스로 상태를 관리하지 않아요. 모든 상태는 부모인 Accordion 컴포넌트가 제어하고 있습니다. 그래서 이걸 제어 컴포넌트라고 부르는 거예요!

 

⚔️ 제어 컴포넌트 vs 비제어 컴포넌트

[비제어 컴포넌트]

- 독립적으로 동작

- 간단한 컴포넌트를 만들 때 유용

- 여러 컴포넌트를 동기화하기 어려움

 

[제어 컴포넌트]

- 부모 컴포넌트가 모든 상태를 관리

여러 컴포넌트를 쉽게 조정할 수 있음

부모에서 관리할 상태가 많아지면 복잡해질 수 있음

 

🤷‍♀️ 언제 어떤 걸 사용할까?

 

1️⃣ 간단한 컴포넌트를 만들고 있을 땐 비제어 컴포넌트로 시작해도 괜찮습니다.
예를 들어 독립적으로 동작하는 Toggle 버튼 같은 컴포넌트를 만들 때는 비제어 방식이 더 적합할 수 있어요.

 

2️⃣ 여러 컴포넌트가 동기화되어야 한다면 제어 컴포넌트를 사용하는 게 좋아요!
부모가 모든 상태를 관리하면 조정이 훨씬 쉬워지고 유지보수도 간편해지겠죠? 

 

컴포넌트를 설계할 때는 어떤 정보가 부모에서 제어될 필요가 있는 어떤 정보가 독립적으로 동작해야 하는지를 고민해볼 필요가 있는 것 같아요. 그래도 공식 문서가 말하고 있듯이 처음에는 비제어 방식으로 시작했다가 나중에 제어 방식으로 리팩토링하는 것도 충분히 가능하니까 너무 겁먹지 말기~

 

🧐 단일 진리의 원천?

리액트에서 state는 데이터를 관리하고 화면에 반영하는 가장 중요한 도구 중 하나예요. 그런데 복잡한 애플리케이션을 만들다 보면 같은 데이터를 여러 컴포넌트에서 사용해야 하는 상황이 생길 수 있는데요! 이럴 때 리액트는 단일 진리의 원천이라는 원칙을 따릅니다.

 

단일 진리 원천이 뭔데?

간단히 말해 state를 소유하는 "단 하나의 컴포넌트를 정하자!" 는 거예요.

모든 state가 한 곳에만 있어야 한다는 뜻은 아닙니다. 대신 특정 state를 관리하고 조정할 "주인" 컴포넌트를 명확히 정하자는 것!

 

Accordion 컴포넌트를 다시 떠올려볼까요,,! 여러 Panel 컴포넌트가 활성화 상태(isActive)를 필요로 했었는데 이 상태를 각 Panel 컴포넌트가 독립적으로 관리한다면 서로의 상태를 동기화하기 어려울 거예요. 이 문제를 해결하기 위해 공통 부모 컴포넌트인 Accordion이 이 상태를 "소유"하도록 만들었는데 이렇게 하면 모든 패널의 상태를 조정할 수 있었던 걸 기억할 수 있습니다.

 

왜 단일 진리의 원천이 중요할까?

 

1️⃣ 데이터 중복 방지
같은 데이터를 여러 컴포넌트에서 관리하면 데이터가 서로 다르게 변할 위험이 있습니다.
공통 부모가 state를 소유하면 데이터는 한 곳에서만 관리되고 중복이 없어져요.

 

2️⃣ 컴포넌트 간의 동기화
여러 컴포넌트가 같은 state를 필요로 할 때 단일 진리의 원천을 통해 동기화가 쉬워져요.
아까 했던 것처럼 패널 하나가 열리면 다른 패널은 닫히도록 쉽게 조정할 수 있습니다.

 

3️⃣ 더 나은 유지보수성
state가 한 곳에서 관리되면 문제가 생겼을 때 해당 상태를 확인하고 수정하기 쉬워집니다.

 

state는 대체 어디에 있어야 하는 걸까?!

이제부터 리액트에서 state가 생존할 위치를 정할 때 아래 3가지를 고려하면 좋을 것 같아요!

 

1️⃣ state를 사용하는 컴포넌트는 어디인가요?

state가 특정 컴포넌트에서만 사용된다면 그 컴포넌트가 state를 소유하면 돼요.
ex) 입력 필드의 값(state)은 보통 해당 입력 필드 컴포넌트 안에만 있으면 충분!

 

2️⃣ 여러 컴포넌트가 같은 state를 사용하나요?

그렇다면 공통 부모 컴포넌트가 state를 소유하는 게 좋아요.

 

3️⃣ state가 더 상위로 올라가야 하나요?

만약 자식 컴포넌트의 동작을 부모나 형제 컴포넌트가 알아야 한다면 state를 공통 부모로 끌어올리세요!

 

 

리액트 애플리케이션을 개발하면서 state를 여기 두는 게 맞을까? 하는 고민이 들 수 있는데!
처음에 state를 자식 컴포넌트에 두었다가 필요하면 부모로 끌어올리는 과정은 아주 자연스러운 일이라고 합니다.

리액트의 설계는 이런 변화에 쉽게 대응할 수 있도록 되어 있으니까 천천히 고민하며 구조를 잡아가면 된다고 공식 문서가 계속 말해주네요 ^^,,,

 


궁금한 점

state를 어디에 두어야 하는지 결정하기 애매할 땐,, 어떻게 해야할까,, 사실 state가 어디에 있어야할지 명확할 땐 고민을 별로 안 하게 되는데 상태를 중간 레벨 컴포넌트에 두는 게 맞을지 최상위 부모로 끌어올리는 게 맞을지 이런 건 어떻게 판단해야 할지 궁금해요...! 단일 진리의 원천을 따르더라도 너무 많은 state가 한 곳에 몰리면 복잡해질 수 있는데 이런 상황은 어떻게 해결할 수 있을지,,,ㅠㅠ 혹시 웨비들은 이런 상황을 겪어보고 해결한 경험이 있나요?!

 

그리고 단일 진리의 원천이 항상 옳은 걸까? 이런 것도 궁금해지네용

작은 애플리케이션에선 단일 진리의 원천이 당연히 유리하지만 규모가 커지면 어떤 문제가 생길 수 있을지도 궁금해요