본문 바로가기
Front-End/React

[ React ] React 기본 ⑨ 컴포넌트 트리에 데이터 공급하기 - Context

by 2CHAE._.EUN 2022. 10. 23.

[ 리액트 컴포넌트 트리에 전역적으로 데이터를 공급하는 context API 다루기 ]

 

지금까지 만든 일기 프로젝트의 계층 구조

 

DiaryList 컴포넌트는 App 컴포넌트로부터 DiaryList, onDelete, onEdit의 총 3개의 prop을 받는다.

하지만 onDelete와 onEdit 함수는 DiaryList 컴포넌트에서 사용하지 않고 거쳐 지나가는 용도이다.  전달만 하는

prop가 많을 경우는 코드 작성과 수정에 상당한 악영향을 미치게 된다.  이렇게 부모 컴포넌트로부터 자식 컴포넌트로

전달만 하는 props를 props drilling이라 한다.  props drilling을 해결하기 위해 context가 생겼다. 

 

 

context의 개념은 모든 데이터를 가지고 있는 컴포넌트가 provider라는 공급자 역할을 하는 자식 컴포넌트에게 

자신이 가지고 있는 데이터를 모두 준다. provider 컴포넌트는 자신의 모든 자식 컴포넌트들에게 직통으로

데이터를 줄 수 있다. provider 컴포넌트의 모든 자식 컴포넌트들은 모두 provider 컴포넌트에게 직통으로 데이터를

공급받게 된다. 이렇게 되면 쓸데없이 props만 전달하는 과정이 생기지 않고  props를 전달하는 코드들이 다 사라져

가독성이 좋아지게 된다.

provider 컴포넌트의 자식 컴포넌트들로 배치되어 provider 컴포넌트가 공급하는 모든 데이터에 접근할 수 있는 

컴포넌트의 영역을 Context( 문맥 )이라 한다. provider 컴포넌트 아래에 있는 모든 컴포넌트들은 일기 데이터를

조작하고 관리하는 문맥 하에 존재한다. 즉, 모든 컴포넌트들은 일기 데이터를 관리하기 위한 문맥 속에서 

살아가게 된다. 

 

provider 컴포넌트의 자식으로 배치되지 않은 컴포넌트는 provider 컴포넌트가 공급하는 데이터에 접근할 수 없고

같은 문맥이 아니라고 할 수 있다. 결론적으로는 context를 생성해서 해당 context의 공급자인 provider 컴포넌트에게

모든 데이터를 공급하게 해서 여러 차례 props를 전달하는 props drilling 문제를 효율적으로 해결할 수 있다.

 


[ context 다뤄보기 ]

 

1. context 생성하기

 

const Mycontext = React.createContext(defaultValue);

 

2. context provider를 통한 데이터 공급

 

<MyContext.Provider value={전역으로 전달하고자하는 값}>
	{/*context 안에 위치하는 자식 컴포넌트들*/}
</MyContext.Provider>

 

context 객체는 Provider라는 컴포넌트를 가지고 있기 때문에 컴포넌트로 사용할 수 있다. 또한 컴포넌트 태그 안에

child로 자식 컴포넌트를 전달해줄 수 있다. provider 컴포넌트는 value라는 props를 받아서 props로 받은 value라는

값을 안에 child로 들어가 있는 자식 컴포넌트들에게 전달해주는 기능을 한다. 

 

provider 컴포넌트의 자식으로만 존재하고 있으면 모든 컴포넌트는 provider가 전달하는 모든 값을 사용할 수 있다.

 


[ useContext를 사용해서 context로 부터 값 꺼내오기 ]

 

1. context API를 사용해서 일기 data state를 전역적으로 공급할 수 있도록 도와줄 context를 생성한다.

 

export const DiaryStateContext = React.createContext();

 

context도 export를 해줘야지 다른 컴포넌트들이 context에 접근해서 provider가 제공하는 데이터를 받아올 수 있다.

export default가 아닌 그냥 export를 해주는 이유는 export default는 파일 하나당 한 번밖에 사용할 수 없다.

하지만 export만을 작성할 경우 여러 개를 사용할 수 있다.

 

* export default와 export의 차이

다른 파일에서 import default를 할 수 있는 것은 export default된 요소만 가능하다. 반면에 export가 된 요소들은

비구조화 할당을 통해서만 import를 받을 수 있다.

 

import React, { useRef, useState } from "react";

 

예시로 React는 import default를 한 것이고 useRef와 useState같이 비구조화 할당을 통해 import한 것이다.

결론적으로 App.js에서는 기본적으로 App 컴포넌트를 export를 하고 있고, 부과적으로는 DiaryStateContext를

export 하고 있다고 볼 수 있다.

 

2. DiaryStateContext에 공급자를 만들고 data를 공급하기

 

공급자는 context가 가지고 있는 provider라는 컴포넌트를 사용한다. 사용하는 방법은 App 컴포넌트가 리턴하는

부분에 최상위 태그를 바꿔주면 된다. 

 

return (
    <DiaryStateContext.Provider>
      <div className="App">
        <DiaryEditor onCreate={onCreate} />
        <div>전체 일기 : {data.length}</div>
        <div>emotion이 3이상인 일기 개수 : {goodCount}</div>
        <div>emotion이 3미만인 일기 개수 : {badCount}</div>
        <div>emotion이 3이상인 일기 비율 : {goodRatio}</div>
        <DiaryList onEdit={onEdit} diaryList={data} onDelete={onDelete} />
      </div>
    </DiaryStateContext.Provider>
  );

 

DiaryStateContext.Provider라는 공급자 컴포넌트로  감싸주게 된다면 DiaryStateContext가 App 컴포넌트의 자식

컴포넌트인 DiaryEditor와 DiaryList를 묶고 있다는 것을 확인할 수 있다. 

 

React Developer Tools로 확인

DiaryStateContext가 감싸고 있는 모든 컴포넌트들은 DiaryStateContext가 공급하고 있는 모든 데이터를 어디서든

가져가서 사용할 수 있다. 

 

data를 공급하기 위해서는 DiaryStateContext.Provider 컴포넌트에게 value prop를 사용해야 한다. 

이렇게 value prop으로 provider 컴포넌트에게 내려준 값은 언제든지 가져가서 사용할 수 있는 값이다. 

 

DiaryStateContext.Provider가 결론적으로 공급하고 싶은 값은 App 컴포넌트가 가지고 있는 data state이다.

그래서 value prop에 data state를 전달해주면 된다.

 

  return (
    <DiaryStateContext.Provider value={data}>
      <div className="App">
        <DiaryEditor onCreate={onCreate} />
        <div>전체 일기 : {data.length}</div>
        <div>emotion이 3이상인 일기 개수 : {goodCount}</div>
        <div>emotion이 3미만인 일기 개수 : {badCount}</div>
        <div>emotion이 3이상인 일기 비율 : {goodRatio}</div>
        <DiaryList onEdit={onEdit} diaryList={data} onDelete={onDelete} />
      </div>
    </DiaryStateContext.Provider>
  );

 

3. context 밑에 있는 자식 요소가 data를 직접 사용해보기

 

DiaryList 컴포넌트는 App 컴포넌트로부터 data state를 prop으로 전달을 받아서 map 함수를 통해서 랜더링을 한다.

App 컴포넌트의 data를 diaryList 형태로 받았었지만 이젠 props으로 받을 필요 없이 context에서 공급을 받으면

된다.

 

context에서 값을 꺼내기 위해서는 useContext()을 사용한다. useContext()에 인자로는 값을 꺼내고 싶은

context를 전달해줘야한다. useContext 기능을 통해서 context를 지정해서 그 context로부터 값을 꺼내올 수 있다.

 

import { DiaryStateContext } from "./App";

const diaryList = useContext(DiaryStateContext);

 

DiaryList.js

import { useContext } from "react";
import { DiaryStateContext } from "./App";
import DiaryItem from "./DiaryItem";

const DiaryList = ({ onDelete, onEdit }) => {
  const diaryList = useContext(DiaryStateContext);

  return (
    <div className="DiaryList">
      <h2>일기 리스트</h2>
      <h4>{diaryList.length}개 일기가 있습니다.</h4>
      <div>
        {diaryList.map((it) => (
          <DiaryItem key={it.id} {...it} onDelete={onDelete} onEdit={onEdit} />
        ))}
      </div>
    </div>
  );
};

DiaryList.defaultProps = {
  //undefined 방지용으로 기본값 설정
  diaryList: [],
};

export default DiaryList;

 

DiaryList 컴포넌트가 context에서 data state를 잘 꺼내쓰기 때문에 App 컴포넌트에서는 따로 prop으로 data

state를 전달할 필요가 없어지게 된다. 

 


[ props drilling이 발생하는 컴포넌트 처리해보기 ]

 

DiaryStateContext가 내려주는 value props를 Provider 컴포넌트에게 전달하는 value에 data만 넣는 것이 아니라

onDelete, onEdit을 전달해주면 된다 생각할 수 있지만 전달해주면 안된다.

 

Provider도 결과적으로는 컴포넌트이기 때문에 prop이 변경되면 재생성된다. Provider 컴포넌트가 재생성되면

그 밑에 있는 컴포넌트들도 같이 재생성이 된다. Provider 컴포넌트에게 data와 함께 onDelete, onEdit를 같이

전달해주게 된다면 data state가 변경될 때마다 리랜더링이 일어나게 되서 최적화가 다 풀려버리게 된다.

 

이럴 때는 문맥을 중첩으로 사용한다. DiaryStateContext는 오직 data state를 공급하기 위해서만 존재를 하도록

만들어주고, state를 변화시키는 상태 변화 함수인 dispatch를 위한 새로운 context를 만들어준다.

* context는 생성하기 위한 개수 제한이 없다.

 

export const DiaryDispatchContext = React.createContext();

 

return (
    <DiaryStateContext.Provider value={data}>
      <DiaryDispatchContext.Provider>
        <div className="App">
          <DiaryEditor onCreate={onCreate} />
          <div>전체 일기 : {data.length}</div>
          <div>emotion이 3이상인 일기 개수 : {goodCount}</div>
          <div>emotion이 3미만인 일기 개수 : {badCount}</div>
          <div>emotion이 3이상인 일기 비율 : {goodRatio}</div>
          <DiaryList onEdit={onEdit} onDelete={onDelete} />
        </div>
      </DiaryDispatchContext.Provider>
    </DiaryStateContext.Provider>
  );

 

중첩으로 사용하게 될 경우 Developer Tools를 통해 이중으로 문맥이 감싸진 것을 확인할 수 있다.

 

 

위에 존재하는 Context.Provider는 DiaryStateContext provider이고 아래 존재하는 Context.Provider는 

DiaryDispatchContext provider이다. 

 

DiaryDispatchContext provider의 value로 onCreate, onDelete, onEdit을 전달해준다면 onCreate, onDelete,

onEdit는 재생성되지 않는 함수들로만 구성이 되어있기 때문에 리랜더링이 되지 않는다.

 

 onCreate, onDelete, onEdit를 하나로 묶어주고 재생성되지 않도록 dependency array에 빈 배열을 전달해준다.

함수들을 하나로 묶을 때 useMemo로 묶어주는 이유는 만약 useMemo를 사용하지 않고 전달해준다면 App

컴포넌트가 재생성이 될 때 함수를 하나로 묶어준 객체도 재생성이 되어 최적화가 풀리게 된다. 그래서 useMemo를

사용해서 재생성되지 않도록 묶어줘야한다. 

 

  const memoizedDispatches = useMemo(() => {
    return { onCreate, onDelete, onEdit };
  }, []);

  return (
    <DiaryStateContext.Provider value={data}>
      <DiaryDispatchContext.Provider value={memoizedDispatches}>
        <div className="App">
          <DiaryEditor/>
          <div>전체 일기 : {data.length}</div>
          <div>emotion이 3이상인 일기 개수 : {goodCount}</div>
          <div>emotion이 3미만인 일기 개수 : {badCount}</div>
          <div>emotion이 3이상인 일기 비율 : {goodRatio}</div>
          <DiaryList/>
        </div>
      </DiaryDispatchContext.Provider>
    </DiaryStateContext.Provider>
  );

 

DiaryDispatchContext provider의 value로 함수 3개를 묶어준 객체를 전달해준다면 DiaryEditor와 DirayList

컴포넌트에 prop으로 따로 함수를 전달해주지 않아도 된다.

 

1. DiaryEditor 컴포넌트는 따로 prop을 전달받지 않아도 되지만 useContext를 통해서 onCreate를 받아와야한다.

이때 onCreate는 비구조화 할당으로 받아와야 한다. 왜냐하면 DiaryDispatchContext가 공급하고 있는 값은

함수 3개로 구성이 되어있는 객체이기 때문에 반드시 비구조화 할당으로 받아와야 한다.

 

const { onCreate } = useContext(DiaryDispatchContext);

 

DiaryEditor.js

import React, { useContext, useRef, useState } from "react";
import { DiaryDispatchContext } from "./App";

const DiaryEditor = () => {
  const { onCreate } = useContext(DiaryDispatchContext);

  const [state, setState] = useState({
    author: "",
    content: "",
    emotion: 1,
  });

  const handleChangeState = (e) => {
    setState({
      ...state,
      [e.target.name]: e.target.value,
    });
  };

  const authorInput = useRef();
  const contentInput = useRef();

  const handleSubmit = () => {
    if (state.author.length < 1) {
      authorInput.current.focus();
      return;
    }

    if (state.content.length < 1) {
      contentInput.current.focus();
      return;
    }

    onCreate(state.author, state.content, state.emotion); // 새로운 일기 데이터 추가

    alert("저장 성공");

    setState({
      // 입력 폼에 남아있는 데이터를 초기화
      author: "",
      content: "",
      emotion: 1,
    });
  };

  return (
    <div className="DiaryEditor">
      <h2>오늘의 일기</h2>
      <div>
        <input
          ref={authorInput}
          name="author"
          value={state.author}
          onChange={handleChangeState}
        ></input>
      </div>
      <div>
        <textarea
          ref={contentInput}
          name="content"
          value={state.content}
          onChange={handleChangeState}
        ></textarea>
      </div>
      <div>
        <select
          name="emotion"
          value={state.emotion}
          onChange={handleChangeState}
        >
          <option value={1}>1</option>
          <option value={2}>2</option>
          <option value={3}>3</option>
          <option value={4}>4</option>
          <option value={5}>5</option>
        </select>
      </div>
      <div>
        <button onClick={handleSubmit}>일기 저장하기</button>
      </div>
    </div>
  );
};

export default React.memo(DiaryEditor);

 

2. DiaryList에서도 onDelete와 onEdit를 더이상 DirayItem의 prop으로 전달해 줄 필요 없다.

 

DiaryList.js

import { useContext } from "react";
import { DiaryStateContext } from "./App";
import DiaryItem from "./DiaryItem";

const DiaryList = () => {
  const diaryList = useContext(DiaryStateContext);

  return (
    <div className="DiaryList">
      <h2>일기 리스트</h2>
      <h4>{diaryList.length}개 일기가 있습니다.</h4>
      <div>
        {diaryList.map((it) => (
          <DiaryItem key={it.id} {...it} />
        ))}
      </div>
    </div>
  );
};

DiaryList.defaultProps = {
  //undefined 방지용으로 기본값 설정
  diaryList: [],
};

export default DiaryList;

 

3. DiaryItem에서는 onDelete와 onEdit 함수를 useContext를 통해서 받아와야한다. 

 

DiaryItem.js

import React, { useContext, useEffect, useRef, useState } from "react";
import { DiaryDispatchContext } from "./App";

const DiaryItem = ({ author, content, emotion, created_date, id }) => {
  const handleRemove = () => {
    if (window.confirm(`${id}번째 일기를 정말 삭제하시겠습니까?`)) {
      onDelete(id); // '예'일 경우 삭제 함수에 id를 전달해준다.
    }
  };

  const { onDelete, onEdit } = useContext(DiaryDispatchContext);

  const [isEdit, setIsEdit] = useState(false);

  const toggleIsEdit = () => setIsEdit(!isEdit);

  const [localContent, setLocalContent] = useState(content);

  const handleQuitEdit = () => {
    setIsEdit(false);
    setLocalContent(content);
  };

  const localContentInput = useRef();

  const handleEdit = () => {
    if (localContent.length < 1) {
      localContentInput.current.focus();
      return;
    }

    if (window.confirm(`${id}번째 일기를 수정하시겠습니까?`)) {
      onEdit(id, localContent);
      toggleIsEdit(); // 일기를 수정 완료하면 수정 폼을 다시 false로 만들어서 닫아주기
    }
  };

  return (
    <div className="DiaryItem">
      <div className="info">
        <span>
          작성자 : {author} | 감정 : {emotion}
        </span>
        <br />
        <span className="date">{new Date(created_date).toLocaleString()}</span>
      </div>
      <div className="content">
        {isEdit ? (
          <>
            <textarea
              ref={localContentInput}
              value={localContent}
              onChange={(e) => setLocalContent(e.target.value)}
            />
          </>
        ) : (
          <>{content}</>
        )}
      </div>
      {isEdit ? (
        <>
          <button onClick={handleQuitEdit}>수정취소</button>
          <button onClick={handleEdit}>수정완료</button>
        </>
      ) : (
        <>
          <button onClick={handleRemove}>삭제하기</button>
          <button onClick={toggleIsEdit}>수정하기</button>
        </>
      )}

      <br />
    </div>
  );
};

export default React.memo(DiaryItem);

 

App.js

import React, {
  useCallback,
  useEffect,
  useMemo,
  useReducer,
  useRef,
} from "react";
import "./App.css";
import DiaryEditor from "./DiaryEditor";
import DiaryList from "./DiaryList";

const reducer = (state, action) => {
  switch (action.type) {
    case "INIT": {
      return action.data;
    }
    case "CREATE": {
      const created_date = new Date().getTime();
      const newItem = {
        ...action.data,
        created_date,
      };
      return [newItem, ...state];
    }
    case "DELETE": {
      return state.filter((it) => it.id !== action.targetId);
    }
    case "EDIT": {
      return state.map((it) =>
        it.id === action.targetId ? { ...it, content: action.newContent } : it
      );
    }
    default:
      return state;
  }
};

export const DiaryStateContext = React.createContext();
export const DiaryDispatchContext = React.createContext();

function App() {
  // data state는 일기 데이터 배열을 저장하기 때문에 배열로 초기값을 만들어준다.
  const [data, dispatch] = useReducer(reducer, []);

  const dataId = useRef(1); // ID는 1부터 시작

  const getData = async () => {
    const res = await fetch(
      "https://jsonplaceholder.typicode.com/comments"
    ).then((res) => res.json());

    const initData = res.slice(0, 10).map((it) => {
      return {
        author: it.email,
        content: it.body,
        emotion: Math.floor(Math.random() * 5) + 1,
        created_date: new Date().getTime(),
        id: dataId.current++,
      };
    });
    dispatch({ type: "INIT", data: initData });
  };

  useEffect(() => {
    getData();
  }, []);

  const onCreate = useCallback((author, content, emotion) => {
    dispatch({
      type: "CREATE",
      data: { author, content, emotion, id: dataId.current },
    });

    dataId.current += 1;
  }, []);

  const onDelete = useCallback((targetId) => {
    dispatch({ type: "DELETE", targetId });
  }, []);

  const onEdit = useCallback((targetId, newContent) => {
    dispatch({ type: "EDIT", targetId, newContent });
  }, []);

  const getDiaryAnalysis = useMemo(() => {
    // emotion이 3이상인 일기의 개수
    const goodCount = data.filter((it) => it.emotion >= 3).length;

    // emotion이 3미만인 일기의 개수
    const badCount = data.length - goodCount;

    // emotion이 3이상인 일기 비율
    const goodRatio = (goodCount / data.length) * 100;

    return { goodCount, badCount, goodRatio };
  }, [data.length]);

  //getDiaryAnalysis()의 반환값이 객체이므로 객체로 비구조화 할당을 받는다.
  const { goodCount, badCount, goodRatio } = getDiaryAnalysis;

  const memoizedDispatches = useMemo(() => {
    return { onCreate, onDelete, onEdit };
  }, []);

  return (
    <DiaryStateContext.Provider value={data}>
      <DiaryDispatchContext.Provider value={memoizedDispatches}>
        <div className="App">
          <DiaryEditor />
          <div>전체 일기 : {data.length}</div>
          <div>emotion이 3이상인 일기 개수 : {goodCount}</div>
          <div>emotion이 3미만인 일기 개수 : {badCount}</div>
          <div>emotion이 3이상인 일기 비율 : {goodRatio}</div>
          <DiaryList />
        </div>
      </DiaryDispatchContext.Provider>
    </DiaryStateContext.Provider>
  );
}

export default App;