TypeScript와 React - #2 리액트 적용

sham·2021년 12월 10일
0

Betti 개발일지

목록 보기
8/14

https://velog.io/@velopert/create-typescript-react-component

프로젝트 생성

https://velog.io/@swimme/React-Typescript-시작하기

npx create-react-app ts-tutorial --template typescript

기존의 CRA에서 뒤에 typescript만 붙여주면 타입스크립트 설정이 적용된 프로젝트가 생성된다.

이미 만든 프로젝트에 타입스크립트를 적용하고 싶다면 다음과 같이 설정해주자.

npm install typescript @types/node @types/react @types/react-dom @types/jest
// yarn add typescript @types/node @types/react @types/react-dom @types/jest

타입스크립트로 만드는 컴포넌트

App.tsx

import React from 'react';
import logo from './logo.svg';
import './App.css';

const App: React.FC = () => {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

최근의 추세는 화살표 함수가 아닌 function 키워드를 사용하는 것이 일반적.

React.FC라는 타입을 이용해 타입을 지정해 줄 수 있는데, 일장일단이 있다고 한다.

Greeting.tsx

import React from 'react';

type GreetingsProps = {
  name: string;
};

const Greetings: React.FC<GreetingsProps> = ({ name }) => (
  <div>Hello, {name}</div>
);

export default Greetings;

컴포넌트의 props에 대한 타입을 선언할 때는 type, interface 둘 중 아무거나 사용해줘도 되지만 한 프로젝트 내부에서 일관성은 지켜야 한다.

React.FC 의 장단점

장점

  1. props에 children이 들어간다?
  2. defaultProps, propTypes, contextTypes를 설정할 때 자동완성이 된다?

단점

  1. children이 옵셔널 형태로 들어가있기에 props의 타입이 명백하지 않게 된다.
    1. 어떤 컴포넌트에는 children이 들어가면 안되기에 각 Props의 타입마다 children을 명시해야 한다고 한다.

결론

React.FC에 children props가 들어있는 건 장점이 아니다?

React.FC를 사용하면 props에 children이 들어간다고 하는데 React.FC이 없어도 children은 들어가는 것을 확인했다!

children이란?

https://velog.io/@donggu/문과생이-설명하는-React-propsproperties-children

A 컴포넌트 사이에 B라는 컴포넌트가 있을 때, B 컴포넌트 내용을 보여주려고 사용하는 props.

// App.js
import React from 'react';
import Greeting from './Greeting'
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <Greeting text="hello">
          <div>between1</div>
          <div>between2</div>
        </Greeting>
      </header>
    </div>
  );
}

export default App;

// Greeting.js

import React from 'react';

type GreetingsProps = {
    text: string;
};

const Greetings: React.FC<GreetingsProps> = (props) => {
    console.log(props);
    return (
        <div>
            Hello, {props.text}
            {props.children}
        </div>
    );
};

export default Greetings;

기존에는 특정 컴포넌트 안에 또다른 컴포넌트를 띄우고 싶다면 해당 컴포넌트에서 리턴해주는 방식을 사용했지만, props 안에 있는 children을 이용한다면 안에 들어있는 컴포넌트를 그대로 쏴줄 수 있게 된다.

React.FC는 없는 게 낫다!

// App.tsx
import React from 'react';
import Greetings from './Greeting';

const App: React.FC = () => {
  return <Greetings name="Hello" />; // Property 'mark' is missing in type '{ name: string; }' but required in type 'GreetingsProps'.  TS2741
};

export default App;

// Greeting.tsx
import React from 'react';

type GreetingsProps = {
    name: string;
    mark: string;
};

const Greeting: React.FC<GreetingsProps> = ({ name, mark }) => ( // mark = '!' 로 교체
    <div>
        Hello, {name} {mark}
    </div>
);

Greeting.defaultProps = {
    mark: '!'
};

export default Greeting;

다음 코드에서 mark 를 defaultProps 로 넣었음에도 불구하고 mark값이 없다면서 제대로 작동하지 않는다. 비구조화 할당을 하는 과정에서 기본값을 설정해주는 것으로 해결해줄 수 있지만 defaultProps는 의미가 없어진다.

import React from 'react';

type GreetingsProps = {
  name: string;
  mark: string;
};

const Greetings = ({ name, mark }: GreetingsProps) => (
  <div>
    Hello, {name} {mark}
  </div>
);

Greetings.defaultProps = {
  mark: '!'
};

export default Greetings;

// function 형태

function Greetings({ name, mark }: GreetingsProps) {
  return (
    <div>
      Hello, {name} {mark}
    </div>
  );
}

Greetings.defaultProps = {
  mark: '!'
};

export default Greetings;

React.FC를 제거해주면 잘 작동하게 된다.

생략 할 수 있는 props 설정

props 중에서 생략해도 되는 값이 있다면 ? 문자를 사용할 수 있다.

// Greeting.tsx

import React from 'react';

type GreetingsProps = {
  name: string;
  mark: string;
  optional?: string;
};

function Greetings({ name, mark, optional }: GreetingsProps) {
  return (
    <div>
      Hello, {name} {mark}
      {optional && <p>{optional}</p>}
    </div>
  );
}

Greetings.defaultProps = {
  mark: '!'
};

export default Greetings;

함수에 들어올 props의 타입으로 name, mark, optional을 명시했다. ?가 붙은 optional은 값이 들어오지 않아도 된다는 것을 의미한다.

props로 (콜백) 함수가 넘어올 때

// App.tsx
import React from 'react';
import Greetings from './Greeting';

const App: React.FC = () => {
  const handleClick = (text: string) => {
    console.log(text)
    return (text);
  }
  return <Greetings name="Hello" optional="asd" onClick={handleClick} />;
};

export default App;

// Greeting.tsx
import React, { useState } from 'react';

type GreetingsProps = {
    name: string;
    mark: string;
    optional?: string;
    onClick: (name: string) => string; // string인 name을 인자로 받아 string을 리턴함을 의미한다.
};

function Greetings({ name, mark, optional, onClick }: GreetingsProps) {
    const [data, setData] = useState("");

    const handleClick = () => {
        setData(onClick(name));
    }
    return (
        <div>
            Hello, {name} {mark}
            {optional && <p>{optional}</p>}
            <div>
                <button onClick={handleClick}>Click Me</button>
                data : {data}
            </div>
        </div>
    );
}

Greetings.defaultProps = {
    mark: '!'
};

export default Greetings;

props로 콜백함수가 넘어올 때, 인자의 타입, 리턴하는 값의 타입을 지정해주면 된다.

정리

  • React.FC 는 별로 좋지 않다.
  • 함수형 컴포넌트를 작성 할 때는 화살표 함수로 작성해도 되고, function 키워드를 사용해도 된다.
  • Props 에 대한 타입을 선언 할 땐 interface 또는 type 을 사용하면 되며, 프로젝트 내부에서 일관성만 지키면 된다.

타입스크립트로 Hooks 사용하기

useState

// App.tsx
import React from 'react';
import Counter from './Counter';

const App: React.FC = () => {

  return <Counter />;
};

export default App;

// Counter.tsx

import React, { useState } from 'react';

function Counter() {
    const [count, setCount] = useState<number>(0);
    const onIncrease = () => setCount(count + 1);
    const onDecrease = () => setCount(count - 1);
    const wrongSet = () => setCount("wrong");
    return (
        <div>
            <h1>{count}</h1>
            <div>
                <button onClick={onIncrease}>+1</button>
                <button onClick={onDecrease}>-1</button>
                <button onClick={wrongSet}>wrong</button> // Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>'.

            </div>
        </div>
    );
}

export default Counter;

리액트의 그것과 크게 다르지 않다. useState를 선언해줄 때 어떤 값을 취급할지 타입에 대해서 명시하는 것만 다른다.

사실, 제네릭을 사용하지 않더라도 설정한 초기값에 맞게 알아서 타입을 유추해서 설정해준다. 상태가 null일 수도 있고 아닐 수도 있거나 상태의 타입이 까다로운 구조를 가진 객체나 배열일 경우 제네릭을 활용해주면 좋다고 한다.

type Information = { name: string; description: string };
const [info, setInformation] = useState<Information | null>(null);
type Todo = { id: number; text: string; done: boolean };
const [todos, setTodos] = useState<Todo[]>([]);  // { id: number; text: string; done: boolean } 형태의 객체를 요소로 가지는 배열의 타입이라는 뜻.
const [todos, setTodos] = useState([] as Todo[]);

배열의 경우 위처럼 빈 배열만 넣으면 타입을 추론 할 수 없기 때문에 Generics을 명시하거나 as 키워드를 사용해주어야 한다.

onChange, onSubmit

// App.tsx
import React from 'react';
import MyForm from './MyForm';

const App: React.FC = () => {
  const onSubmit = (form: { name: string; description: string }) => {
    console.log(form);
  };
  return <MyForm onSubmit={onSubmit} />;
};

export default App;

// MyForm.tsx
import React, { useState } from 'react';

type MyFormProps = {
    onSubmit: (form: { name: string; description: string }) => void;
};

function MyForm({ onSubmit }: MyFormProps) {
    const [form, setForm] = useState({
        name: '',
        description: ''
    });

    const { name, description } = form;

    const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        console.log(e);
        const { name, value } = e.target;
        setForm({
            ...form,
            [name]: value
        });
    };

    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        onSubmit(form);
        setForm({
            name: '',
            description: ''
        }); // 초기화
    };

    return (
        <form onSubmit={handleSubmit}>
            <input name="name" value={name} onChange={onChange} />
            <input name="description" value={description} onChange={onChange} />
            <button type="submit">등록</button>
        </form>
    );
}

export default MyForm;

코드가 꽤나 복잡한데, 차근차근 풀어보자.

type MyFormProps의 조건은 onSubmit이라는 함수가 들어있어야 하는데 해당 함수는 form이라는 객체를 인자로 받아 아무것도 리턴하지 않는다. 인자인 form은 문자열인 name과 description라는 key로 이루어져 있다.

함수 컴포넌트 MyForm는 props로 onSubmit을 받고 onSubmit의 타입은 방금 설정한 MyFormProps에 부합해야 한다.

onChange, onSubmit 시 발동되는 콜백 함수에는 e가 넘어가는데, 이벤트 리스너마다 고유한 타입이 정해져 있는 모양이다. vscode 상에서 해당 이벤트 리스너 위에 마우스를 올리면 어떤 타입을 넣어야 하는지 친절하게 알려준다.

Screen Shot 2021-11-24 at 4.33.00 PM.png

cannot use jsx unless the '--jsx' flag is provided 이슈

https://chacha73.tistory.com/44

tsconfig.json에서 "jsx": "react-jsx"을 "preserve"로 바꿔주면 된다고는 하지만, 다시 실행하면 react-jsx로 원상복구 된다.

vscode의 setting.json에서 "typescript.tsdk": "/Users/sham/.brew/lib/node_modules/typescript/lib" 를 설정해주고 ts 파일 하단의 status bar에서 typescript 버전을 갱신해주면 해결된다.

useReducer

import React, { useReducer } from 'react';

type Action = { type: 'INCREASE' } | { type: 'DECREASE' }; // 사용되는 모든 액션을 나열한다.

function reducer(state: number, action: Action): number {
    switch (action.type) {
        case 'INCREASE':
            return state + 1;
        case 'DECREASE':
            return state - 1;
        default:
            throw new Error('Unhandled action');
    }
}

function Counter() {
    const [count, dispatch] = useReducer(reducer, 0);
    const onIncrease = () => dispatch({ type: 'INCREASE' });
    const onDecrease = () => dispatch({ type: 'DECREASE' });

    return (
        <div>
            <h1>{count}</h1>
            <div>
                <button onClick={onIncrease}>+1</button>
                <button onClick={onDecrease}>-1</button>
            </div>
        </div>
    );
}

export default Counter;

reducer를 선언할 때 state의 타입과 리턴하는 타입은 동일해야 한다. 위의 코드는 단순한 카운터기에 state의 타입을 number로 지정해주었지만, 만약 state가 객체 형태라면 타입이나 인터페이스로 타입을 지정해주어야 할 것이다.

import React, { useReducer } from 'react';

type Color = 'red' | 'orange' | 'yellow';

type State = {
  count: number;
  text: string;
  color: Color;
  isGood: boolean;
};

type Action =
  | { type: 'SET_COUNT'; count: number }
  | { type: 'SET_TEXT'; text: string }
  | { type: 'SET_COLOR'; color: Color }
  | { type: 'TOGGLE_GOOD' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'SET_COUNT':
      return {
        ...state,
        count: action.count // count가 자동완성되며, number 타입인걸 알 수 있습니다.
      };
    case 'SET_TEXT':
      return {
        ...state,
        text: action.text // text가 자동완성되며, string 타입인걸 알 수 있습니다.
      };
    case 'SET_COLOR':
      return {
        ...state,
        color: action.color // color 가 자동완성되며 color 가 Color 타입인걸 알 수 있습니다.
      };
    case 'TOGGLE_GOOD':
      return {
        ...state,
        isGood: !state.isGood
      };
    default:
      throw new Error('Unhandled action');
  }
}

function ReducerSample() {
  const [state, dispatch] = useReducer(reducer, {
    count: 0,
    text: 'hello',
    color: 'red',
    isGood: true
  });

  const setCount = () => dispatch({ type: 'SET_COUNT', count: 5 }); // count 를 넣지 않으면 에러발생
  const setText = () => dispatch({ type: 'SET_TEXT', text: 'bye' }); // text 를 넣지 않으면 에러 발생
  const setColor = () => dispatch({ type: 'SET_COLOR', color: 'orange' }); // orange 를 넣지 않으면 에러 발생
  const toggleGood = () => dispatch({ type: 'TOGGLE_GOOD' });

  return (
    <div>
      <p>
        <code>count: </code> {state.count}
      </p>
      <p>
        <code>text: </code> {state.text}
      </p>
      <p>
        <code>color: </code> {state.color}
      </p>
      <p>
        <code>isGood: </code> {state.isGood ? 'true' : 'false'}
      </p>
      <div>
        <button onClick={setCount}>SET_COUNT</button>
        <button onClick={setText}>SET_TEXT</button>
        <button onClick={setColor}>SET_COLOR</button>
        <button onClick={toggleGood}>TOGGLE_GOOD</button>
      </div>
    </div>
  );
}

export default useRef;

타입에 대한 지정만 추가되었을 뿐 useReducer와 큰 차이는 없다.

useRef

리액트 컴포넌트에서 외부 라이브러리의 인스턴스 또는 DOM 을 특정 값 안에 담을 때 useRef를 사용한다. ref가 변하더라도 렌더링은 되지 않는다.

변수 값 관리

useRef 사용 시 실제 값은 .current로 접근하서 얻게 된다. 제네릭으로 타입을 명시할 수 있다.

const id = useRef<number>(0);
  const increaseId = () => {
    id.current += 1;
  }
import React, { useState, useRef } from 'react';

type MyFormProps = {
  onSubmit: (form: { name: string; description: string }) => void;
};

function MyForm({ onSubmit }: MyFormProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  const [form, setForm] = useState({
    name: '',
    description: ''
  });

  const { name, description } = form;

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setForm({
      ...form,
      [name]: value
    });
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    onSubmit(form);
    setForm({
      name: '',
      description: ''
    });
    if (!inputRef.current) {
      return;
    }
    inputRef.current.focus();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={name} onChange={onChange} ref={inputRef} />
      <input name="description" value={description} onChange={onChange} />
      <button type="submit">등록</button>
    </form>
  );
}

export default MyForm;

이름을 입력하는 input 엘리먼트를 조작하기 위해서 ref에 inputRef를 걸었다. handleSubmit이 실행되면 inputRef을 이용해서 input에 하이라이트가 되게끔 조작한다.

inputRef.current 값을 사용하려면 null 체크를 해주어야 하는데, 권장이 아닌 필수인지 빼먹고 렌더링하려 하면 Object is possibly 'null'. TS2531라는 에러가 발생한다.

import React from 'react';
import MyForm from './MyForm';

const App: React.FC = () => {
  const onSubmit = (form: { name: string; description: string }) => {
    console.log(form);
  };
  return <MyForm onSubmit={onSubmit} />;
};

export default App;

타입스크립트로 Context API 사용하기

상태 전용 Context와 dispatch 전용 Context 두 개의 Context를 만들어준다.

하나의 Context로 상태, dispatch를 전부 관리해주면 안될까 싶었는데, 상태 값은 필요없고 dispatch만 필요한 컴포넌트 역시 상태가 업데이트 될 때 같이 렌더링하게 된다. 낭비되는 렌더링을 방지할 수 있다.

상태 Context, 디스패치 Context 설정

import { createContext, Dispatch } from 'react';

export type Todo = {
  id: number;
  text: string;
  done: boolean;
};

type TodosState = Todo[];

const TodosStateContext = createContext<TodosState | undefined>(undefined);

type Action =
  | { type: 'CREATE'; text: string }
  | { type: 'TOGGLE'; id: number }
  | { type: 'REMOVE'; id: number };

type TodosDispatch = Dispatch<Action>;
const TodosDispatchContext = createContext<TodosDispatch | undefined>(
  undefined
);

모듈처럼 쓸 수 있게 별개의 파일로 만들어서 관리한다. 위의 TodosStateContext는 Todo라는 타입을 배열의 요소로 하는 TodosState 타입을 따른다. TodosDispatchContexts는 형태가 Action과 같은 dispatch가 담겨있다. 액션에 필요한 값이 존재하지 않다면 오류를 뱉을 것이다.

Provider를 사용하지 않을 시 Context 값이 undefined가 되어야 한다는데, 타입에 명시해주자.

리듀서 설정

import { createContext, Dispatch } from 'react';

export type Todo = {
  id: number;
  text: string;
  done: boolean;
};

type TodosState = Todo[];

const TodosStateContext = createContext<TodosState | undefined>(undefined);

type Action =
  | { type: 'CREATE'; text: string }
  | { type: 'TOGGLE'; id: number }
  | { type: 'REMOVE'; id: number };

type TodosDispatch = Dispatch<Action>;
const TodosDispatchContext = createContext<TodosDispatch | undefined>(
  undefined
);

function todosReducer(state: TodosState, action: Action): TodosState {
  switch (action.type) {
    case 'CREATE':
      const nextId = Math.max(...state.map(todo => todo.id)) + 1;
      return state.concat({
        id: nextId,
        text: action.text,
        done: false
      });
    case 'TOGGLE':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, done: !todo.done } : todo
      );
    case 'REMOVE':
      return state.filter(todo => todo.id !== action.id);
    default:
      throw new Error('Unhandled action');
  }
}

Provider 설정

import React, { createContext, Dispatch, useReducer } from 'react';

(...) // (이전 코드 생략)

export function TodosContextProvider({ children }: { children: React.ReactNode }) {
    const [todos, dispatch] = useReducer(todosReducer, [
        {
            id: 1,
            text: 'Context API 배우기',
            done: true
        },
        {
            id: 2,
            text: 'TypeScript 배우기',
            done: true
        },
        {
            id: 3,
            text: 'TypeScript 와 Context API 함께 사용하기',
            done: false
        }
    ]);

    return (
        <TodosDispatchContext.Provider value={dispatch}>
            <TodosStateContext.Provider value={todos}>
                {children}
            </TodosStateContext.Provider>
        </TodosDispatchContext.Provider>
    );
}

커스텀 Hooks 작성

import React, { createContext, Dispatch, useReducer, useContext } from 'react';

export function useTodosState() {
  const state = useContext(TodosStateContext);
  if (!state) throw new Error('TodosProvider not found');
  return state;
}

export function useTodosDispatch() {
  const dispatch = useContext(TodosDispatchContext);
  if (!dispatch) throw new Error('TodosProvider not found');
  return dispatch;
}

useContext 를 사용해서 Context 안의 값을 사용할 때 해당 값이 유효한지 확인해야 하는데, 커스텀 훅을 사용해서 유호성 검사를 자동으로 하게 할 수 있다.

컴포넌트에서 Context 사용하기

App 컴포넌트에서 TodosContextProvider 를 불러와서 기존 내용을 감싸주어야 한다.

// src/App.tsx

import React from 'react';
import TodoForm from './components/TodoForm';
import TodoList from './components/TodoList';
import { TodosContextProvider } from './contexts/TodosContext';

const App = () => {
  return (
    <TodosContextProvider>
      <TodoForm />
      <TodoList />
    </TodosContextProvider>
  );
};

export default App;

Context 조회하기 (useTodosState)

// src/components/TodoList.tsx

import React from 'react';
import TodoItem from './TodoItem';
import { useTodosState } from '../contexts/TodosContext';

function TodoList() {
  const todos = useTodosState();
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem todo={todo} key={todo.id} />
      ))}
    </ul>
  );
}

export default TodoList;

Dispatch 사용하기 - 등록 (useTodosDispatch)

// src/components/TodoForm.tsx

import React, { useState } from 'react';
import { useTodosDispatch } from '../contexts/TodosContext';

function TodoForm() {
  const [value, setValue] = useState('');
  const dispatch = useTodosDispatch();

  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    dispatch({
      type: 'CREATE',
      text: value
    });
    setValue('');
  };

  return (
    <form onSubmit={onSubmit}>
      <input
        value={value}
        placeholder="무엇을 하실 건가요?"
        onChange={e => setValue(e.target.value)}
      />
      <button>등록</button>
    </form>
  );
}

export default TodoForm;

Dispatch 사용하기 - 토글, 제거 (useTodosDispatch)

import React from 'react';
import './TodoItem.css';
import { useTodosDispatch, Todo } from '../contexts/TodosContext';

type TodoItemProps = {
  todo: Todo; // TodoContext 에서 선언했던 타입을 불러왔습니다.
};

function TodoItem({ todo }: TodoItemProps) {
  const dispatch = useTodosDispatch();

  const onToggle = () => {
    dispatch({
      type: 'TOGGLE',
      id: todo.id
    });
  };

  const onRemove = () => {
    dispatch({
      type: 'REMOVE',
      id: todo.id
    });
  };

  return (
    <li className={`TodoItem ${todo.done ? 'done' : ''}`}>
      <span className="text" onClick={onToggle}>
        {todo.text}
      </span>
      <span className="remove" onClick={onRemove}>
        (X)
      </span>
    </li>
  );
}

export default TodoItem;
profile
씨앗 개발자

0개의 댓글