반동으로 컨텍스트 마이그레이션

51737 단어 reactrecoil

이유



오늘은 Create React App TypeScript Todo Example 2020 에서 Recoil로 컨텍스트를 마이그레이션했습니다.
프로젝트는 신기술 실험/평가를 돕기 위해 최신 기술 스택을 최대한 유지하는 개념입니다.

컨텍스트 코드



이전에는 간단한 컨텍스트 전역 저장소를 @laststance/use-app-state 사용했습니다.
코드는 여기에 있습니다.

  • src/index.tsx<Provider appState={} >

  • import React from 'react'
    import ReactDOM from 'react-dom'
    import { Router } from '@reach/router'
    import Provider from '@laststance/use-app-state'
    import './index.css'
    import * as serviceWorker from './serviceWorker'
    import App, { LocalStorageKey } from './App'
    import ErrorBoundary from './ErrorBoundary'
    import { NotFound } from './NotFound'
    
    export type Routes = '/' | '/active' | '/completed'
    
    export interface Todo {
      id: string
      bodyText: string
      completed: boolean
    }
    
    export type TodoListType = Todo[]
    
    export interface AppState {
      todoList: TodoListType
    }
    
    const BlankAppState: AppState = {
      todoList: [],
    }
    
    function LoadingAppStateFromLocalStorage(BlankAppState: AppState): AppState {
      const stringifiedJSON: string | null = window.localStorage.getItem(
        LocalStorageKey.APP_STATE
      )
      if (typeof stringifiedJSON === 'string') {
        const Loaded: AppState = JSON.parse(stringifiedJSON)
        return Loaded
      }
    
      return BlankAppState
    }
    
    const initialAppState = LoadingAppStateFromLocalStorage(BlankAppState)
    
    interface Props {
      path: Routes
    }
    const Controller: React.FC<Props> = ({ path }) => <App path={path} />
    
    ReactDOM.render(
      <ErrorBoundary>
        <Provider initialState={initialAppState}>
          <Router>
            <Controller path="/" />
            <Controller path="/active" />
            <Controller path="/completed" />
            <NotFound default />
          </Router>
        </Provider>
      </ErrorBoundary>,
      document.getElementById('root')
    )
    
    


  • src/App/index.tsxuseAppState()

  • import React, { useEffect } from 'react'
    import { useAppState } from '@laststance/use-app-state'
    import TodoTextInput from './TodoTextInput'
    import TodoList from './TodoList'
    import Menu from './Menu'
    import Copyright from './Copyright'
    import { Routes, AppState } from '../index'
    import { RouteComponentProps } from '@reach/router'
    import { Container } from './style'
    
    export enum LocalStorageKey {
      APP_STATE = 'APP_STATE',
    }
    
    interface Props {
      path: Routes
    }
    
    const App: React.FC<Props & RouteComponentProps> = ({ path }) => {
      const [appState] = useAppState<AppState>()
    
      // if appState has changes, save it LocalStorage.
      useEffect((): void => {
        window.localStorage.setItem(
          LocalStorageKey.APP_STATE,
          JSON.stringify(appState) // convert JavaScript Object to string
        )
      }, [appState])
    
      return (
        <Container>
          <section className="todoapp">
            <TodoTextInput />
            {appState.todoList.length ? (
              <>
                <TodoList path={path} />
                <Menu path={path} />
              </>
            ) : null}
          </section>
          <Copyright />
        </Container>
      )
    }
    
    export default App
    

    컨텍스트를 구성하는 방법을 알고 싶다면 @laststance/use-app-state internal code directory like this 을 읽을 수 있습니다.
    짧고 매우 간단합니다.

    반동 코드



    이 섹션은 위 항목에서 마이그레이션된 코드를 보여줍니다.

  • src/dataStructure.tsx는 리코일atom 쪽을 만듭니다.

  • import { atom, RecoilState } from 'recoil'
    
    export type Routes = '/' | '/active' | '/completed'
    
    export interface Todo {
      id: string
      bodyText: string
      completed: boolean
    }
    
    export type TodoListType = Todo[]
    
    export interface AppState {
      todoList: TodoListType
    }
    
    export enum LocalStorageKey {
      APP_STATE = 'APP_STATE',
    }
    
    function LoadAppStateFromLocalStorage(): AppState {
      const stringifiedJSON: string | null = window.localStorage.getItem(
        LocalStorageKey.APP_STATE
      )
      if (typeof stringifiedJSON === 'string') {
        const Loaded: AppState = JSON.parse(stringifiedJSON)
        return Loaded
      }
    
      const BlankAppState: AppState = {
        todoList: [],
      }
    
      return BlankAppState
    }
    
    export const initialAppState: RecoilState<AppState> = atom({
      key: 'initialAppState',
      default: LoadAppStateFromLocalStorage(),
    })
    


  • src/index.tsx<RecoilRoot>

  • import React from 'react'
    import ReactDOM from 'react-dom'
    import { Router } from '@reach/router'
    import { RecoilRoot } from 'recoil'
    import './index.css'
    import * as serviceWorker from './serviceWorker'
    import App from './App'
    import ErrorBoundary from './ErrorBoundary'
    import { NotFound } from './NotFound'
    import { Routes } from './dataStructure'
    
    interface Props {
      path: Routes
    }
    const Controller: React.FC<Props> = ({ path }) => <App path={path} />
    
    ReactDOM.render(
      <ErrorBoundary>
        <RecoilRoot>
          <Router>
            <Controller path="/" />
            <Controller path="/active" />
            <Controller path="/completed" />
            <NotFound default />
          </Router>
        </RecoilRoot>
      </ErrorBoundary>,
      document.getElementById('root')
    )
    


  • src/App/index.tsxuseRecoilValue()

  • import React, { useEffect } from 'react'
    import { useRecoilState } from 'recoil'
    import NewTodoInput from './NewTodoInput'
    import TodoList from './TodoList'
    import UnderBar from './UnderBar'
    import Copyright from './Copyright'
    import { RouteComponentProps } from '@reach/router'
    import { Layout } from './style'
    import {
      AppState,
      initialAppState,
      LocalStorageKey,
      Routes,
    } from '../dataStructure'
    
    interface Props {
      path: Routes
    }
    
    const App: React.FC<Props & RouteComponentProps> = ({ path }) => {
      const [appState] = useRecoilState<AppState>(initialAppState)
    
      // if appState has changes, save it LocalStorage.
      useEffect((): void => {
        window.localStorage.setItem(
          LocalStorageKey.APP_STATE,
          JSON.stringify(appState) // convert JavaScript Object to string
        )
      }, [appState])
    
      return (
        <Layout>
          <section className="todoapp">
            <NewTodoInput />
            {appState.todoList.length ? (
              <>
                <TodoList path={path} />
                <UnderBar path={path} />
              </>
            ) : null}
          </section>
          <Copyright />
        </Layout>
      )
    }
    
    export default App
    

    테스팅(β)



    처음에는 jest.mock 에 의해 생성된 프로덕션 코드 recoilState 를 재정의하기 위해 atom() 방법을 시도했습니다.

    그러나 jest.mock 방법은 jest.fn() 또는 mockImplementation() 으로 recoilState 생성 코드를 재정의하기 위해 recoilStateFactory 와 같은 약간 까다로운 구현이 필요합니다.

    그래서 이렇게 간단한 Recoil State Setter 컴포넌트를 만들었습니다.

  • src/testUtils.tsx

  • import React, { useEffect } from 'react'
    import { useRecoilState } from 'recoil'
    import { AppState, initialAppState } from './dataStructure'
    
    interface Props {
      testEnvironmentInitialAppState?: AppState
    }
    
    export const InjectTestingRecoilState: React.FC<Props> = ({
      testEnvironmentInitialAppState = {
        todoList: [],
      },
    }) => {
      const [, setAppState] = useRecoilState<AppState>(initialAppState)
    
      useEffect(() => {
        setAppState(testEnvironmentInitialAppState)
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [])
    
      return null
    }
    

    테스트 코드에서 테스트 값을 Recoil State로 설정하는 데 사용합니다.

  • src/앱/TodoList/index.test.tsx

  • import React from 'react'
    import { render, fireEvent } from '@testing-library/react'
    import TodoList from './index'
    import { RecoilRoot } from 'recoil'
    import '@testing-library/jest-dom'
    import { AppState } from '../../dataStructure'
    import { InjectTestingRecoilState } from '../../testUtils'
    
    const initialAppState: AppState = {
      todoList: [
        {
          id: 'TsHx9eEN5Y4A',
          bodyText: 'monster',
          completed: false,
        },
        {
          id: 'ba91OwrK0Dt8',
          bodyText: 'boss black',
          completed: false,
        },
        {
          id: 'QwejYipEf5nk',
          bodyText: 'caffe latte',
          completed: false,
        },
      ],
    }
    
    test('should be render 3 todo items in initialAppState', () => {
      const screen = render(
        <RecoilRoot>
          <InjectTestingRecoilState
            testEnvironmentInitialAppState={initialAppState}
          />
          <TodoList path="/" />
        </RecoilRoot>
      )
    
      expect(screen.getByTestId('todo-list')).toBeInTheDocument()
      expect(screen.getByTestId('todo-list').children.length).toBe(3)
      expect(Array.isArray(screen.getAllByTestId('todo-item'))).toBe(true)
      expect(screen.getAllByTestId('todo-item')[0]).toHaveTextContent('monster')
      expect(screen.getAllByTestId('todo-item')[1]).toHaveTextContent('boss black')
      expect(screen.getAllByTestId('todo-item')[2]).toHaveTextContent('caffe latte')
    })
    
    test('should be work delete todo button', () => {
      const screen = render(
        <RecoilRoot>
          <InjectTestingRecoilState
            testEnvironmentInitialAppState={initialAppState}
          />
          <TodoList path="/" />
        </RecoilRoot>
      )
    
      // delete first item
      fireEvent.click(screen.getAllByTestId('delete-todo-btn')[0])
      // assertions
      expect(screen.getByTestId('todo-list').children.length).toBe(2)
      expect(Array.isArray(screen.getAllByTestId('todo-item'))).toBe(true)
      expect(screen.getAllByTestId('todo-item')[0]).toHaveTextContent('boss black')
      expect(screen.getAllByTestId('todo-item')[1]).toHaveTextContent('caffe latte')
    })
    
    test('should be work correctly all completed:true|false checkbox toggle button', () => {
      const screen = render(
        <RecoilRoot>
          <InjectTestingRecoilState
            testEnvironmentInitialAppState={initialAppState}
          />
          <TodoList path="/" />
        </RecoilRoot>
      )
    
      // toggle on
      fireEvent.click(screen.getByTestId('toggle-all-btn'))
      // should be completed all todo items
      expect((screen.getAllByTestId('todo-item-complete-check')[0] as HTMLInputElement).checked).toBe(true) /* eslint-disable-line prettier/prettier */
      expect((screen.getAllByTestId('todo-item-complete-check')[1] as HTMLInputElement).checked).toBe(true) /* eslint-disable-line prettier/prettier */
      expect((screen.getAllByTestId('todo-item-complete-check')[2] as HTMLInputElement).checked).toBe(true) /* eslint-disable-line prettier/prettier */
    
      // toggle off
      fireEvent.click(screen.getByTestId('toggle-all-btn'))
      // should be not comleted all todo items
      expect((screen.getAllByTestId('todo-item-complete-check')[0] as HTMLInputElement).checked).toBe(false) /* eslint-disable-line prettier/prettier */
      expect((screen.getAllByTestId('todo-item-complete-check')[1] as HTMLInputElement).checked).toBe(false) /* eslint-disable-line prettier/prettier */
      expect((screen.getAllByTestId('todo-item-complete-check')[2] as HTMLInputElement).checked).toBe(false) /* eslint-disable-line prettier/prettier */
    })
    

    그러나 구현을 완료한 후 문서에서 다른 더 나은 솔루션을 찾았습니다.

    <RecoilRoot>의 initializeState Props



    Docs에 따르면 <RecoilRoot> Recoil State 설정 기능을 Props로 받아들입니다.
    이것은 Redux를 계속하고 구성 요소 테스트에 실제로 소수이기 때문에 기존<Provider /> 패턴과 매우 유사합니다.

    다음 작업으로 이 옵션을 시도해 보겠습니다.

    이 게시물을 읽어 주셔서 감사합니다! 🤗

    좋은 웹페이지 즐겨찾기