Migrate Context to Recoil

Reason

Today I've just migrate Context to Recoil in the Create React App TypeScript Todo Example 2020.
The project is stand by concept that keep latest tech-stack as much as possible for helping new tech experiment/evaluate people.

Context code

Previously I used to @laststance/use-app-state that simple Context global store.
Code is Here.

  • src/index.tsx <Provider appState={} > side
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.tsx useAppState() side
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

If you want to know how organizing Context, you can read @laststance/use-app-state internal code directory like this.
It's short and super simple.

Recoil Code

This section shows migrated code from above stuff.

  • src/dataStructure.tsx create Recoil atom side
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> side
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.tsx useRecoilValue() side
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

Testing(β)

Initially I tried jest.mock way to override production code recoilState that created by atom().

But jest.mock way need little bit tricky implementation like recoilStateFactory to override recoilState creation code by jest.fn() or mockImplementation().

So I created simple Recoil State Setter component like this.

  • 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
}

And use it in testing code to set test value to Recoil State

  • src/App/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 */
})

But after completed the implementation I find out another better solution on Docs.

initializeState Props of <RecoilRoot>

According to the Docs, <RecoilRoot> accept Recoil State setting function as a Props.
This is so similar to traditional <Provider /> pattern since continuing Redux and really handful for Component testing.

I'll try this option as a next task.

Thank you for reading this post! 🤗