React _ Recoil을 활용한 전역 상태 관리 도입 후기

kyle kwon·2022년 11월 15일
0

React

목록 보기
8/15
post-thumbnail

Prologue

이번에는 Recoil을 도입해서 작은 어플리케이션을 만들어 본 후기와 recoil의 기본적인 개념에 대해 정리해보겠습니다. ContextAPI와 hook을 통해서 전역 상태 관리에 활용했을 때, useReducer를 활용하면 다양한 상태 변수들을 한꺼번에 관리가 가능하긴 했지만, 기존의 상태 변수들을 변화시켜 다른 컴포넌트에 사용하는 것은 불가능하였습니다. Context API가 무조건 전역 상태 관리에만 쓰이는 것이 아닌, 재사용성이 높은 컴포넌트를 만들 때도 유용하기 때문에 자주 사용됩니다. 하지만, 전역 상태 관리에 특화된 라이브러리들이 많은 가운데, React 팀에서 만든 Recoil을 도입해서 공부하고, 활용해보기로 했습니다.




Recoil Concept

Recoil에서 가장 중요한 개념은 AtomsSelector라고 할 수 있습니다.
처음 접한 저도 개념을 한 번더 정리하는 차원에서 두 주요 개념에 대해서 정리해보겠습니다.


Atom

Recoil 공식문서에서는 다음과 같이 정의하고 있습니다. Atoms는 상태의 단위이고, 업데이트와 구독이 가능합니다. atom이 업데이트 되면 각각의 구독된 컴포넌트는 새로운 값을 반영해서 다시 렌더링합니다.

하나의 atom이 여러 컴포넌트에서 사용되는 경우에 모든 컴포넌트는 상태를 공유합니다.

먼저 Atoms는 atom 함수를 사용해서 다음과 같이 코드를 작성합니다.

const colorState = atom<string | undefined>({
  key: 'colorState',
  default: '#e5e5e5',
});

atom 함수를 활용해서 Atom을 생성할 때는 unique한 ID를 key로 설정해주고, 해당 기본값을 설정해줍니다. 일종의 상태 단위이기 때문에, useState hook의 state와 유사하다고 볼 수 있습니다.

immutable, 즉 둘 모두 불변성을 지켜주는 것이 중요하지만, 차이점으로는 atom에서는 별도의 unique한 ID를 key로 설정해준다는 점이 있습니다.

그럼, 생성한 atom을 읽고, 쓰고, 업데이트 하는 등 활용하려면 어떻게 해야 할까요?
useState hook처럼 유사한 형태를 가진 useRecoilState를 활용하면 됩니다.

// ColorChange.tsx
import { useRecoilState } from 'recoil'
import { colorState } from '../store/ColorContext'

function ColorChange ():JSX.Element{
 	const [color, setColor] = useRecoilState(colorState);
  	return <div style={{color: color}}>Recoil</div>
}

// Nav.tsx
import { useRecoilState } from 'recoil'
import { colorState } from '../store/ColorContext'

function Nav ():JSX.Element {
 	const [color, setColor] = useRecoilState(colorState);
  	return <p style={{color: color}}>This is Recoil Atom </p>
}

위와 같이 useRecoilState의 인자로 처음에 설정한 atom 함수를 할당한 변수 colorState를 담고 있고, useState처럼 destructuring 문법으로, state인 color와 setState와 같은 형태인 setColor가 있습니다.


Selector

Selector는 서로 다른 컴포넌트가 공유하고 있는 가장 기본의 atom들을 기준으로 파생하는(변화시킨) 데이터들을 계산하는데 사용되는 순수 함수입니다.
Selector는 atom이나 다른 selector들도 입력으로 받아들입니다. 처음 공식 문서에서 컨텐츠들을 읽었을 때 크게 와닿지 않았었는데, 코드를 보면 조금은 이해가 갑니다. 어떻게 사용하는 지 살펴 보겠습니다.

export const fontSizeState = atom<number | undefined>({
  key: 'fontSizeState',
  default: 14,
})

export const fontSizeLabel = selector({
  key: 'updatedFontSize',
  get: ({ get }) => {
    const fontSize = get(fontSizeState)
    const unit = 'px'

    return `${fontSize}${unit}`
  },
})

위 코드에서 보면 fontSizeLabel selector는 상단에 선언하고 할당한 fontSizeState라는 atom에 의존을 합니다. get이라는 함수 인자를 전달해, 이 get 함수는 fontSizeState를 인자로 삼고 함수를 호출한 값을 fontSize라는 변수에 할당한 것을 볼 수 있습니다.
즉, selector는 fontSizeState를 입력 값으로 받고, px 단위fontSize 값을 반환하는 순수 함수처럼 작동합니다. 따라서, 공식문서에서 순수 함수라고 정의하는 것입니다.

이번에는 주어진 상태의 값을 반환하는 읽기 전용 함수라고 볼 수 있는 useRecoilValue()를 활용해서 selector가 반환하는 값을 읽어오겠습니다.

// ColorContext.ts
export const fontSizeLabel = selector({
  key: 'updatedFontSize',
  get: ({ get }) => {
    const fontSize = get(fontSizeState)
    const unit = 'px'

    return `${fontSize}${unit}`
  },
})

// Main.tsx
function Main() {
	const fontSizeUnit = useRecoilValue<string | undefined>(fontSizeLabel)
    return <div fontSize={fontSizeUnit}>This is Selector read by useRecoilValue </div>
}

selector 함수가 반환하는 값을 담은 fontSizeLabeluseRecoilValue가 인자로 받고, 이 값을 div DOM 요소에 스타일링을 위한 props로 할당해주었습니다.

이렇게 기본적인 코드 구조가 어떻게 되는지 파악하고, 간단한 예시를 통해 Atom과 Selector 그리고, useRecoilstate(), useRecoilValue()를 활용해보았습니다.

그럼 이번에는 제가 구현해 본 간단한 UI 어플리케이션에 어떻게 Recoil을 적용했는지 살펴보겠습니다.




React + TypeScript + Styled-Components + Recoil Example

Typescript + Recoil을 활용해서 간단한 UI 어플리케이션을 만들어보았습니다.

어플리케이션에 처음 접근 시 로그인 화면이 보이고, 정해진 ID(1234), PW(1234)를 통해 로그인하면, react-router를 활용한 라우팅 처리를 통해 Main(Home) 페이지로 이동하고, 상단의 Navigation Bar에서 Profile 페이지로 이동도 가능하며, Logout도 가능합니다.

Recoil을 활용한 기능은 다음과 같습니다.

  1. 메인 페이지 진입 후 'FontSize Up'이라고 적힌 버튼을 클리갛면 Welcome을 포함한 텍스트의 크기가 커지는 기능
  2. Navigation Bar 중 가운데 전구 버튼을 토글링하면 배경색이 바뀌는 기능

디렉토리 구조는 다음과 같습니다.

src
ㄴ-- components
------ Main.tsx (로그인 후 접근하는 메인 페이지)
------ MyForm.tsx (로그인 페이지)
------ Nav.tsx (Navigation Bar)
------ Profile.tsx (프로필 페이지)
------ Logout.tsx (로그아웃 버튼 컴포넌트)

-- store
---- ColorContext.ts (Recoil의 atom과 selector를 담는 보관소)

App.tsx
index.tsx

규모가 큰 프로젝트는 아니기에, 페이지와 재사용되는 컴포넌트를 따로 분리 없이 components 디렉토리 하나에 모두 담았습니다.

이제 코드를 중요한 컴포넌트와 Atom, Selector를 담고 있는 Store 위주로 살펴보겠습니다.


App.tsx

// App.tsx
import React from 'react'
import styled from 'styled-components'
import { RecoilRoot } from 'recoil'
import GlobalStyle from './lib/GlobalStyle'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import MyForm from './components/MyForm'
import Main from './components/Main'
import Profile from './components/Profile'

function App(): JSX.Element {
  return (
    <RecoilRoot>
      <GlobalStyle />
      <Block>
        <BrowserRouter>
          <Routes>
            <Route path="/" element={<MyForm />} />
            <Route path="/main" element={<Main />} />
            <Route path="/profile" element={<Profile />} />
          </Routes>
        </BrowserRouter>
      </Block>
    </RecoilRoot>
  )
}

export default App

const Block = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
  height: 100vh;
`

App 컴포넌트는 위와 같이 라우팅 처리를 하며, 미리 세팅해 둔 GlobalStyle이 모든 컴포넌트에 영향을 주고 있는 것을 볼 수 있습니다. 한 가지 눈여겨 볼 점은 RecoilRoot 입니다.

RecoilRoot는 우리가 생성한 atom이나 selector에 의해 파생된 상태들을 모든 컴포넌트들이 공유할 수 있는 전역 상태가 되도록 하는 기반으로, 최상단의 컴포넌트에 감싸 하위의 컴포넌트들이 이 상태를 활용할 수 있도록 합니다.



ColorContext.ts - Atom과 Selector를 담고 있는 컴포넌트

import { atom, selector } from 'recoil'

export const colorState = atom<string | undefined>({
  key: 'colorState',
  default: '#FBFBFE',
})

export const fontSizeState = atom<number | undefined>({
  key: 'fontSizeState',
  default: 14,
})

export const fontSizeLabel = selector({
  key: 'updatedFontSize',
  get: ({ get }) => {
    const fontSize = get(fontSizeState)
    const unit = 'px'

    return `${fontSize}${unit}`
  },
})


MyForm.tsx - 로그인 화면을 담당하는 컴포넌트

// MyForm.tsx
import React, { useState } from 'react'
import styled from 'styled-components'
import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { colorState } from '../store/ColorContext'
import Nav from './Nav'

function MyForm() {
  const color = useRecoilValue<string | undefined>(colorState)

  const [inputContent, setInputContent] = useState({
    id: '',
    pw: '',
  })

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    console.log(inputContent)
    setInputContent({ ...inputContent, [e.target.id]: e.target.value })
  }
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
  }

  const navigate = useNavigate()
  return (
    <>
      <Nav />
      <Block color={color}>
        <Form onSubmit={handleSubmit}>
          <Label htmlFor="id">ID</Label>
          <InputId
            type="text"
            value={inputContent.id}
            id="id"
            placeholder="아이디"
            onChange={handleChange}
          />
          <Label htmlFor="pw">PW</Label>
          <InputPassWord
            type="text"
            id="pw"
            placeholder="비밀번호"
            value={inputContent.pw}
            onChange={handleChange}
            maxLength={12}
          />
          <SubmitButton
            type="submit"
            value="로그인"
            onClick={() => {
              if (inputContent.id !== '1234' || inputContent.pw !== '1234') {
                alert('you are not permitted')
                return
              }
              navigate('/main')
            }}
          />
        </Form>
      </Block>
    </>
  )
}

export default MyForm

const Block = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  ${({ color }) => color && `background-color : ${color}`};
`

const Form = styled.form`
  display: flex;
  flex-direction: column;
  padding: 2rem;
  width: 300px;
  border-radius: 1rem;
  background-color: #fff;
`

const Label = styled.label`
  font-size: 0.9rem;
  font-weight: 600;
`

const InputId = styled.input`
  margin-bottom: 1rem;
  padding: 0.5rem 1rem;
  border: 1px solid #e1e1e1;
  border-radius: 1rem;
  &:focus {
    border: 1px solid #ffa000;
  }
`

const InputPassWord = styled.input`
  margin-bottom: 1.2rem;
  padding: 0.5rem 1rem;
  border: 1px solid #e1e1e1;
  border-radius: 1rem;
  &:focus {
    border: 1px solid #ffa000;
  }
`

const SubmitButton = styled.input`
  width: 100%;
  padding: 0.5rem 1rem;
  border: 1px solid #e1e1e1;
  border-radius: 1rem;

  &:hover {
    background-color: #ffa000;
    color: #fff;
  }
`

Recoil을 활용한 부분은 다음과 같습니다.
const color = useRecoilValue<string | undefined>(colorState)

<Block color={color}></Block>
  
 const Block = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  ${({ color }) => color && `background-color : ${color}`};
`

ColorContext.ts에서 생성한 Atom 함수가 담긴 colorState를 인자로 받아와 useRecoilValue에 담아 호출한 결과값을 color에 할당하고, Block 요소에 props로 전달하여 styling에 활용하였습니다. 즉, 배경색을 바꾸는 역할을 하는 것이겠죠? 전구 토글링에 따라 바꾸는 setColor 함수는 Nav 컴포넌트에 담겨 있습니다.

바로 확인해 보겠습니다.



import React from 'react'
import { useNavigate } from 'react-router-dom'
import styled, { css } from 'styled-components'
import { useRecoilState } from 'recoil'
import { colorState } from '../store/ColorContext'

function Nav() {
  const [color, setColor] = useRecoilState(colorState)

  const navigate = useNavigate()

  const onChange = (e: React.MouseEvent<HTMLDivElement>) => {
    if (color === '#FBFBFE') {
      setColor('#ffa000')
    } else {
      setColor('#FBFBFE')
    }
  }
  // console.log(color)
  return (
    <>
      <Block name="home">
        <NavEl onClick={() => navigate('/main')}>Home</NavEl>
        <NavEl onClick={onChange}>💡</NavEl>
        <NavEl onClick={() => navigate('/profile')}>Profile</NavEl>
      </Block>
    </>
  )
}

export default Nav

const Block = styled.div<{ name: string }>`
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 0.5rem;
  width: 100%;
  height: 3rem;
  border-bottom: 1px solid #e5e5e5;
  ${props =>
    props.name === 'Login' &&
    css`
      margin-bottom: 180px;
    `}
`

const NavEl = styled.div`
  padding: 0.25rem 1rem;
  font-size: 1rem;
  font-weight: 600;
  text-align: center;
  border-radius: 1rem;
  cursor: pointer;

  &:hover {
    color: #fff;
    background-color: #ffa000;
  }
`

위에서 이야기한 전구 토글링 담당 코드 부분은 이 부분입니다.

  const [color, setColor] = useRecoilState(colorState)

  const onChange = (e: React.MouseEvent<HTMLDivElement>) => {
    if (color === '#FBFBFE') {
      setColor('#ffa000')
    } else {
      setColor('#FBFBFE')
    }
  }

이해하기 어렵지 않죠?


Main.tsx - 로그인 후 접근하는 메인 페이지(Home)

import Nav from './Nav'
import styled, { CSSProperties } from 'styled-components'
import { useRecoilState, useRecoilValue } from 'recoil'
import { colorState, fontSizeLabel, fontSizeState } from '../store/ColorContext'
import Logout from './Logout'

function Main() {
  const color = useRecoilValue<string | undefined>(colorState)
  const [fontSize, setFontSize] = useRecoilState<number | undefined>(fontSizeState)
  const fontSizeUnit = useRecoilValue<string | undefined>(fontSizeLabel)

  const handleClick = () => {
    if (fontSize) {
      setFontSize(fontSize + 1)
      console.log(fontSize) // fontSize 값이 버튼을 클릭할 때마다 증가합니다.
    }
  }
  return (
    <>
      <Nav />
      <Block color={color} fontSize={fontSizeUnit}>
        <h1>Welcome</h1>
        <p>this is home page</p>
        <Button onClick={handleClick}>FontSize Up</Button>
      </Block>
      <Logout />
    </>
  )
}

export default Main

const Block = styled.div<CSSProperties>`
  padding: 1rem;
  width: 100%;
  height: 100%;
  ${({ color }) => color && `background-color : ${color}`};
  ${({ fontSize }) => fontSize && `font-size: ${fontSize}`}
`

const Button = styled.button`
  margin-top: 2rem;
  padding: 0.5rem 1rem;
  width: 150px;
  border: 1px solid #e5e5e5;
  border-radius: 1rem;

  &:hover {
    background-color: #e5e5e5;
  }
`

Recoil을 활용한 부분은 다음과 같습니다.

  const color = useRecoilValue<string | undefined>(colorState)
  const [fontSize, setFontSize] = useRecoilState<number | undefined>(fontSizeState)
  const fontSizeUnit = useRecoilValue<string | undefined>(fontSizeLabel)

  const handleClick = () => {
    if (fontSize) {
      setFontSize(fontSize + 1)
      console.log(fontSize) // fontSize 값이 버튼을 클릭할 때마다 증가합니다.
    }
  }

Nav 컴포넌트는 Navigation Bar 역할을 하고 있기 때문에, Main 컴포넌트 안에서도 import 되고 있습니다. Navigation Bar에 담긴 전구 버튼 토글링을 통해 배경색 전환에 활용된 atom을 이용하기 때문입니다.

또, 'FontSize Up'이라는 버튼을 클릭할 때마다, setFontSize를 호출해 fontSize 상태가 커집니다. 이 fontSizeSelector 함수에 의해 파생된 전역으로 사용 가능한 상태값이기 때문입니다.

결과는 이렇게 확인할 수 있습니다.

[변화 전]

[변화 후]




Conclusion & Review

이렇게 Recoil의 개념에 대해서 익히고, 작은 UI 어플리케이션에 도입해 보았습니다. 사실, 이제 contextAPI와 useState / useReducer를 통해 전역 상태 관리하는 법에 익숙해진 상태이기 때문에, 과연 Recoil을 도입해야 하는가 의문이 들었습니다. 하지만, 전역적으로 공유하는 오리지널한 상태값을 변경시켜 파생된 상태 데이터로 활용할 수 없다는 점과 해당 상태값을 모든 컴포넌트가 공유할 수 없고 props를 통해 전달해야 한다는 점이, Recoil을 도입하면 좋을 것 같다는 생각을 들게 했는데, 좋은 점이 충분히 있는 것 같습니다.

리액트 팀에서 만든 라이브러리이기 때문에, 기존의 리액트의 hook을 사용하는 것과 매우 유사하다는 점, 그리고 redux와 달리 상대적으로 도입 및 배움의 난이도가 높지 않다는 점이 장점인 것 같습니다. 아직 겉핧기 식으로 도입해 보았지만, 이후에 다른 프로젝트 또는 이번에 다룬 간단한 어플리케이션을 업그레이드 시킬 때, Recoil에 담긴 다른 유용한 상태 관리 함수나 유틸들을 배워 활용해보면 더 좋을 것 같습니다.

현재로서 관심있는 부분은 비동기 데이터를 Recoil을 통해 다루는 방법에 대해 관심있습니다. Recoil 공식문서를 바탕으로 공부해 나갈 예정입니다.




참고 문서
Recoil 공식문서

profile
FrontEnd Developer - 현재 블로그를 kyledot.netlify.app으로 이전하였습니다.

0개의 댓글