React를 하면서 내가 놓쳤던 것들 1편

박정훈·2022년 11월 20일
0

React

목록 보기
3/10

내가 사용하는 기술의 메인인 React의 공식문서를 내가 읽어봤나..? 모르는 부분만 슬쩍 찾아보고 끄고 했던 나날들... 이제는 정말 정리 해야한다.
그래서 이 글은 공식문서를 읽어보면서 그냥 사용했던 기술들이나 모르던 내용을 다시 살펴보려고 합니다. :)

리액트는 라이브러리라고 소개하고 있네요. 이제는 라이브러리와 프레임워크의 경계선이 많이 흐릿해졌다고 알고 있습니다. 리액트를 사용해 본 결과 약간의 규칙이 있다고 생각은 합니다. (물론 라이브러리의 냄새가 압도적으로 찐하게 나요... 커스텀 훅 같은 경우가 그나마 규칙..?)

React는 필요한 만큼만 사용하면 된다... 언제나 CRA로만(최근에는 vite로 구축해봤는데 굉장히 빠르던...) 해봤는데 이런 내용이 있었네요.
확실히 라이브러리네...
Add React in One Minute

JSX

아래(위 링크) 예제들은 브라우저가 기본적으로 지원하는 요소들로 사용했다. 물론 동작한다. 그렇지만 JSX라는 선택지도 있다.
babel에서 돌려본 코드다.

"use strict";

/*#__PURE__*/React.createElement("button", {
  onClick: function onClick() {
    return setIsClicked(!isClicked);
  }
}, "Like");

이 친구가 이제 JSX로 작성된건데, 위 코드와 비교하면 아주 직관적이라고 생각된다.

//  "좋아요" <button>을 표시
  <button onClick={() => setIsClicked(!isClicked)}>
    Like
  </button>

JSX는 React 'elements'를 생성한다. Babel은 JSX를 React.createElement() 호출로 컴파일한다.

말자야, 잊지 말자. Props는 읽기 전용이다.

함수 컴포넌트든 클래스 컴포넌트든지 컴포넌트 자체 props를 수정해서는 안된다. props는 순수 함수처럼 동작해야 한다.

State 업데이트는 비동기적일 수도 있다.

이거는 내가 react를 처음 다룰 때 정말 이해하기 힘들었던 동작 중 하나였다. 그때는 비동기라는 개념도 잘 잡혀있지 않았으니 이해가 될리가 없지.
나는 분명 state를 업데이트 시켰는데 왜 나한테 이러는거야! 라고 포효했던게 한두번이 아니었다. 일단 사용하고 본 대가였나...
아무튼, React는 성능을 위해 여러 setState() 호출을 단일 업데이트로 한꺼번에 처리할 수 있다고 한다.

Key

Key는 React가 어떤 항목을 변경할지, 추가할지, 삭제할지 식별하는데 도움을 준다. 엘리먼트를 고유하게 식별하는데 도움을 준다는 의미인거 같다.
또한 Key를 선택하는 가장 좋은 방법은 해당 항목을 고유하게 식별할 수 있는 문자열을 사용하는 것이고, 항목의 순서가 바뀔 수 있는 경우 key에 인덱스를 사용하는 것은 권장하지 않는다고 한다.

제어 컴포넌트 (Controlled Component)

폼에 발생하는 사용자 입력값을 React 컴포넌트에서 state로 제어하는것을 의미한다.

const Form = () => {
  const [name, setName] = useState("");

  const handleChange = (e) => {
    setName(e.target.value);
  };

  const handleSubmit = (e) => {
    // do something..
    alert(`hello, ${name}`);
    e.preventDefault();
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        이름:
        <input type="text" value={name} onChange={handleChange} />
      </label>
      <input type="submit" value="확인" />
    </form>
  );
};

value 어트리뷰트에 state인 name이 박혀있다. input에 표기되는 값은 언제나 state인 name이 되기 때문에 React state는 single source of truth가 된다.

textarea 태그

HTML에서의 textarea는 텍스트를 자식으로 정의한다. 반면에 React에서는 value 어트리뷰트를 사용한다.

const Form = () => {
  const [text, setText] = useState("Malza");

  const handleChange = (e) => {
    setText(e.target.value);
  };

  const handleSubmit = (e) => {
    // do something..
    alert(`텍스트 내용은 ${text}`);
    e.preventDefault();
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        이름:
        <textarea type="text" value={text} onChange={handleChange} />
      </label>
      <input type="submit" value="확인" />
    </form>
  );
};

export default Form;

select 태그

React에서는 selected 어트리뷰트 대신 최상단 select 태그에 value 어트리뷰트를 사용한다.

const Form = () => {
  const [fruit, setFruit] = useState("Orange");

  const handleChange = (e) => {
    setFruit(e.target.value);
  };

  const handleSubmit = (e) => {
    // do something..
    alert(`선택된 과일은 ${fruit}!!`);
    e.preventDefault();
  };

  return (
    <>
      <div>{fruit}</div>
      <form onSubmit={handleSubmit}>
        <label>
          pick your favorite flavor:
          <select value={fruit} onChange={handleChange}>
            <option value="Banana">Banana</option>
            <option value="Apple">Apple</option>
            <option value="Orange">Orange</option>
            <option value="mango">Mango</option>
          </select>
        </label>
        <input type="submit" value="확인" />
      </form>
    </>
  );
};

input, textarea, select 모두 비슷하게 동작한다! 모두 제어 컴포넌트를 구현하는데 value 어트리뷰트를 허용한다.

file input 태그

<input type="file" />

값이 읽기 전용이기에 비제어 컴포넌트다.

다중 입력 제어하기

여러 input 엘리먼트를 제어해야할 때, 각 엘리먼트에 name 어트리뷰트를 추가하고, event.target.name 값을 통해 작업할 수 있다.

const Form = () => {
  const [inputs, setInputs] = useState({
    boolState: true,
    numberState: 0,
  });

  const handleChange = (e) => {
    const target = e.target;
    const value = target.type === "checkbox" ? target.checked : target.value;
    const name = target.name;

    console.log(value, name);
    setInputs((cur) => {
      return {
        ...cur,
        [name]: value,
      };
    });
  };

  const handleSubmit = (e) => {
    // do something..
    e.preventDefault();
  };

  return (
    <>
      체크박스: {String(inputs.boolState)}
      <form onSubmit={handleSubmit}>
        <label>
          <input
            type="checkbox"
            name="boolState"
            checked={inputs.boolState}
            onChange={handleChange}
          />
        </label>
        <br />
        숫자: {inputs.numberState}
        <label>
          <input
            type="number"
            name="numberState"
            value={inputs.numberState}
            onChange={handleChange}
          />function App() {
  const [count, setCount] = useState(0);

  return <Form left={<Left />} right={<Right />} />;
}

export default App;

        </label>
      </form>
    </>
  );
};

Formik

source of truth

React app에서 변경이 일어나는 데이터에 대해서는 source of truth를 하나만 두어야 한다. 즉, 하나의 컴포넌트에서 다루고 있는 상태가 있는데, 다른 컴포넌트 역시 해당 값이 필요하게 되면 가장 가까운 공통 조상으로 끌어올리면 된다. 다른 컴포넌트간의 state를 동기화 시키려고 노력하기보다는 하향식 데이터 흐름을 추천한다.

props로 컴포넌트 넘기기

const Form = ({ left, right }) => {
  const handleSubmit = (e) => {
    // do something..
    e.preventDefault();
  };

  return (
    <>
      {left}
      {right}
    </>
  );
};

export default Form;

// ============================

function App() {
  return <Form left={<Left />} right={<Right />} />;
}

export default App;

외에도 props.children이 있다.

React 엘리먼트는 단지 객체이기 때문에 다른 데이터처럼 prop으로 전달할 수 있다! React에서는 무엇이든지 prop으로 전달 가능하다.

React로 생각하기

  1. UI를 컴포넌트 계층 구조로 나누기
    어떤 것이 컴포넌트가 되어야 할까? 우리가 새로운 함수나 객체를 만들 때처럼 하면 된다. 한 가지 테크닉은 단일 책임 원칙이다. 하나의 컴포넌트가 하나의 역할을 맡아야한다.

  2. React로 정적인 버전 만들기
    데이터 모델을 가지고 UI 렌더링은 되지만 아무 동작도 없는 버전을 만들자. 정적 버전을 만드는 일은 생각은 적게 필요하지만 타이핑은 많이 필요하다!

  3. UI state에 대한 최소한의 표현 정의
    앱을 잘 만들기 위해서는 앱이 필요로 하는 변경 가능한 state의 최소 집합을 생각해봐야 한다. 여기서 중요한 점은 중복배제원칙이다. 최소한의 state를 가지고 표현하라는 말인거 같다. 해당 state에서 뽑아서 사용할 수 있다면 그렇게 하라.

  4. State가 어디에 있어야 할지 정의
    어떤 컴포넌트가 state를 변경하거나 찾아야 할지 알아야 한다. 공통 소유 컴포넌트를 찾아서 state를 두거나 더 상위에 있는 컴포넌트가 state를 가지고 단방향 데이터 흐름을 가지게 한다.

  5. 역방향 데이터 흐름 추가
    컴포넌트는 자신만의 state만 변경이 가능하다. 따라서 state를 변경해주는 함수를 하위로 넘겨준다.

WAI-ARIA

웹 접근성에 관한 내용이다. HTML만으로 만들 수 없는 사용자 인터페이스 컨트롤을 만든다. 다만 HTML로 가능하다면 ARIA로 할 필요는 없다.

접근성 있는 form

input과 textarea 같은 모든 HTML 폼 컨트롤은 구분할 수 있는 라벨이 필요하다. 스크린 리더를 사용하는 사용자들을 위해서 자세한 설명이 담긴 라벨을 제공해야 한다.

JSX에서 for 어트리뷰트만큼은 htmlFor로 사용해야한다.

포커스 컨트롤

모든 웹 애플리케이션은 키보드만 사용하여 모든 동작을 할 수 있어야 한다.
WebAIM

예?

코드 분할

앱의 지연로딩을 위해 필요하며 앱 사용자에게 획기적인 성능 향상을 제공한다. 쉽게 말해 필요하지 않은 코드를 제거한다. 또는 불러오지 않게 한다.

import()

가장 좋은 분할 방법은 동적 import() 문을 사용하는 것이다.

CRA와 Next에서는 즉시 사용 가능합니다.

React.lazy

React.lazy 함수를 사용하면 동적 import를 사용해서 컴포넌트를 렌더링 할 수 있다.

const LazyComponent = lazy(() => import("./components/LazyComponent"));

React.lazy는 동적 import()를 호출하는 함수를 인자로 가진다! 이 함수는 React컴포넌트를 default export로 가진 모듈 객체가 이행되는 Promise를 반환해야한다.

lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링 되어야 하며, Suspense는 lazy 컴포넌트가 로드되길 기다리는 동안 예비 컨텐츠를 보여줄 수 있게 해준다.

일부로 LazyComponent에다가 엄청나게 많은 수의 div를 깔아버리고 사용해봤다.

import { lazy, Suspense } from "react";
import "./App.css";
import Fallback from "./components/Fallback";

const LazyComponent = lazy(() => import("./components/LazyComponent"));

function App() {
  return (
    <div>
      <Suspense fallback={<Fallback />}>
        <LazyComponent />
      </Suspense>
    </div>
  );
}

export default App;

lazy

lazy 로딩 컴포넌트간의 스위칭

import { lazy, Suspense, useState } from "react";
import "./App.css";
import Fallback from "./components/Fallback";

const BigLazyComponent = lazy(() => import("./components/BigLazyComponents"));
const LazyComponent = lazy(() => import("./components/LazyComponent"));

function App() {
  const [tag, setTag] = useState(true);

  const handleClick = () => {
    setTag((cur) => {
      return !cur;
    });
  };

  return (
    <div>
      <button onClick={handleClick}></button>
      <Suspense fallback={<Fallback />}>
        {tag ? <LazyComponent /> : <BigLazyComponent />}
      </Suspense>
    </div>
  );
}

export default App;

사용자가 LazyComponent 아닌 BigLazyComponent를 보고자 하는데, BigLazyComponent가 준비되어 있지 않다면, 사용자는 Fallback을 볼 수 밖에 없다. 하지만 이건 그렇게 좋은 방향은 아니다.
특히 새로운 UI를 준비하는 동안에는 이전의(오래된) UI를 보여주는 것이 좋을 때도 있다. 이때 사용 가능한 것이 startTransition API다.

일단 먼저 startTransition 없이 해봤다.
without startTransition

최초에 Fallback 컴포넌트로 로딩을 보여주는 것을 볼 수 있다. 이후에는 로딩 창 없이 화면을 다시 렌더링 해준다.

startTransition 사용해보자.

  const handleClick = () => {
    startTransition(() => {
      setTag((cur) => {
        return !cur;
      });
    });
  };

with startTransition
Fallback 컴포넌트가 그려주는 로딩이 없다. 이전 화면을 유지하다가 넘어갔다.

리액트에게 tag를 false로 설정하는 것은 당장 급한게 아닌 시간이 걸리는 전환이라는 것을 알린다. 따라서 리액트는 이전 UI를 유지하고 BigLazyComponent가 준비되면 표시한다.

Error boundaries

네트워크 장애와 같은 이유로 모듈 로드에 실패할 경우 에러를 발생시킬 수 있다. 이 때 Error boundaries를 사용해서 사용자의 경험과 복구 관리를 처리할 수 있다.
Error boundaries를 만들고 lazy컴포넌트를 감싼다.

어디서 코드 분할을 시작할까

시작하기 좋은 장소는 라우트다. 대부분의 사용자가 로드하는데 시간이 걸리는 페이지 전환에 익숙하다.

Named Exports

React.lazy는 현재 default exports만 지원한다. named exports를 사용하고자 한다면 default로 이름을 재정의한 중간 모듈을 생성할 수 있고, 이렇게 하면 tree shaking이 계속 동작하며 사용하지 않는 컴포넌트는 가져오지 않는다.

context를 사용하기 전에 고려할 것

context의 주된 용도는 깊숙이 네스팅된 여러 레벨의 컴포넌트들에 데이터를 전달하는 것이다. context를 사용한다면 컴포넌트 재활용이 힘들어진다. 여러 레벨에 걸쳐 props넘기는 걸 대체하는 방식에 context보다 컴포넌트 합성이 좀 더 간단한 해결책 일 수 있다!

// First.tsx
const First = () => {
  const [name] = useState("malza");
  const [game] = useState("롤");
  const [area] = useState("경기");
  
  return (
    <>
      <div>첫번째 컴포넌트입니다.</div>
      <Second name={name} game={game} area={area} />
    </>
  );
};

// Second.tsx
interface IProps {
  name: string;
  game: string;
  area: string;
}

const Second = ({ name, game, area }: IProps) => {
  return (
    <>
      <div>첫번째 컴포넌트입니다. 나는 name이 필요가 없어요.</div>
      <div>game도 필요없어요.</div>
      <div>area도 필요없어요.</div>
      <Third name={name} game={game} area={area}/>
    </>
  );
};

// Third.tsx
const Third = ({ name, game, area }: IProps) => {
  return (
    <>
      <div>세번째 컴포넌트입니다. props로 넘어온 값은 {name}, {game}, {area}입니다.</div>
    </>
  );
};

// App.tsx
function App() {
  return <First />;
}

export default App;

가장 아래 Third에서만 쓰이는데도 불구하고 props를 타고 주구장창 내려보내고 있다. 지금은 세번째 까지만 있지만 더 깊어진다면 아찔하다.
이때는 Third 컴포넌트 자체를 넘겨주면 context를 사용하지 않고 해결 가능하다. 중간에 있는 컴포넌트들은 name, game, area를 알 필요가 없어진다.

// First.tsx
const First = () => {
  const [name] = useState("malza");
  const [game] = useState("롤");
  const [area] = useState("경기");

  const ThirdComp = <Third name={name} game={game} area={area} />;
  return (
    <>
      <div>첫번째 컴포넌트입니다.</div>
      <Second third={ThirdComp} />
    </>
  );
};

// Second.tsx
interface IProps {
  third: React.ReactNode;
}

const Second = ({ third }: IProps) => {
  return (
    <>
      <div>두번째 컴포넌트입니다. 나는 name이 필요가 없어요.</div>
      {third}
    </>
  );
};

제어의 역전

이런 제어의 역전을 이용하면 넘겨줘야 하는 props의 수는 줄고 좀 더 깔끔하게 작성할 수 있다. 다만 이러한 방식이 항상 옳다고 말할 수 없다. 복잡한 로직을 상위로 옮기면 상위 컴포넌트는 더 복잡해지고, 하위 컴포넌트는 필요 이상으로 유연해져야 한다.

다만 같은 데이터를 트리 안 여러 레벨의 많은 컴포넌트에게 해줘야 할 때도 있기 마련이다. 이런 데이터 값이 변할 때마다 모든 하위 컴포넌트에게 방송하는 것이 context이기 때문에 이런 경우는 context를 사용하는게 편리하다.

React.createContext

const MyContext = React.createContext(defaultValue);

defaultValue는 트리 안에서 적절한 Provider를 찾지 못했을 때만 쓰인다!! Provider를 통해 undefined값을 보낸다고 해도 구독하고 있는 컴포넌트들이 defaultValue를 읽지는 않는다!

Context.Consumer

context 변화를 구독하는 React 컴포넌트다. 이 컴포넌트를 사용하면 함수 컴포넌트안에서 context를 구독할 수 있다. Context.Consumer의 자식은 함수여야 하고 context의 현재값을 받으며, React 노드를 반환한다.

function App() {
  return (
    <MyContext.Provider value="말자">
      <First />
    </MyContext.Provider>
  );
}

// Third.tsx
const Third = () => {
  return (
    <MyContext.Consumer>
      {(value) => <div>context의 값은 {value}입니당.</div>}
    </MyContext.Consumer>
  );
};

Context.Consumer

Context.displayName

const MyContext = createContext(defaultValue);
MyContext.displayName = "Malza Provider";

Context.displayName

하위 컴포넌트에서 context 업데이트하기

// App.tsx
const themes = {
  light: {
    backgroundColor: "#fff",
  },
  dark: {
    backgroundColor: "#000",
  },
};

export const MyContext = createContext({
  theme: themes.dark,
  toggleTheme: () => {},
});
MyContext.displayName = "Theme Provider";

function App() {
  const toggleTheme = () => {
    setTheme((cur) => {
      return cur === themes.dark ? themes.light : themes.dark;
    });
  };
  const [theme, setTheme] = useState(themes.dark);
  return (
    <MyContext.Provider
      value={{
        theme,
        toggleTheme,
      }}
    >
      <First />
    </MyContext.Provider>
  );
}

// Third.tsx
const Third = () => {
  return (
    <MyContext.Consumer>
      {({ theme, toggleTheme }) => (
        <button
          onClick={toggleTheme}
          style={{ backgroundColor: theme.backgroundColor }}
        >
          Toggle Theme
        </button>
      )}
    </MyContext.Consumer>
  );
};

하위컴포넌트에서 변경하기

보다시피 첫번째, 두번째, 세번째는 리렌더링 될 필요가 없는데도 렌더링 되고 있다. App만 렌더링되면 되는데 아래 애들까지 다 다시 그리고 있는 것이다. 리렌더링 여부를 정할 때는 참조를 확인하기 때문인데 Provider의 value가 매번 새로운 객체로 내려가고 있기 때문이다. memo를 First에 적용해봤다. 또한 App에다가 console을 추가했다.

memo

1편 끄읏

와 내용 진짜 많다. 어렴풋이만 알고있던 애들을 가볍게나마 만들어서 테스트 하는 재미가 있었다.
Context
여기까지 읽었다. 다음에 다시 돌아와야지! 스터디 준비해야해애

profile
그냥 개인적으로 공부한 글들에 불과

0개의 댓글