TypeScript + React + Recoilで表示、検索、編集UI

## はじめに

[Recoil](https://recoiljs.org/)を使って表示、検索、編集を実装してみた。

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21172/671cf97b-9583-099c-4f19-b0a63ffef1cc.gif)

* デモ
  * https://f96q.github.io/react-recoil-sample/
* ソースコード
  * https://github.com/f96q/react-recoil-sample
 
## State

```ts:src/atoms/app.ts
import { atom } from 'recoil'

export type App = {
  search: string
  openNewItemForm: boolean
}

export const appState = atom<App>({
  key: 'app',
  default: {
    search: '',
    openNewItemForm: false,
  },
})
```

* search
検索ボックスから入力した値を保存するのに使う

* openNewItemForm
追加するフォームが開いてるかどうかの条件を保存するのに使う

```ts:src/atoms/items.ts
import { atom } from 'recoil'

export type Item = {
  id: string
  title: string
  body: string
}

export type ItemForm = Item & {
  edit: boolean
}

export const itemsState = atom<ItemForm[]>({
  key: 'itemsState',
  default: [],
})
```

* id
一意なid、Itemをレンダリングする時のkeyとして使う

* title
タイトル

* body
本文

* edit
一覧に表示されてるItemが編集フォームになっているかの状態の保存に使う

## 表示

```json:items.json
{
  "items": [
    { "id": "2c500677-41ff-4f01-9800-f2750d4f2a08", "title": "title1", "body": "body1" },
    { "id": "7ecfbf0e-b2d0-434a-8eed-f6abcf3f7a19", "title": "title2", "body": "body2" },
    { "id": "69b291d0-bcaa-49ca-ad42-da11bb629863", "title": "title3", "body": "body3" },
    { "id": "3dcf7505-d136-44e0-849e-c357a1d85f81", "title": "title4", "body": "body4" }
  ]
}
```

```jsx:src/components/Items.tsx
import React, { useEffect } from 'react'
import List from '@material-ui/core/List'
import { useRecoilState, useRecoilValue } from 'recoil'
import { itemsState, Item as ItemType } from '../atoms/items'
import { appState } from '../atoms/app'
import { Item } from './Item'
import { New } from './Item/New'

export const Items: React.FC = () => {
  const [items, setItemsState] = useRecoilState(itemsState)
  const app = useRecoilValue(appState)

  useEffect(() => {
    async function fetchItems(): Promise<void> {
      const response = await fetch(process.env.API_ENDPOINT ? `${process.env.API_ENDPOINT}/items.json` : './items.json')
      const json = await response.json()
      setItemsState(() => json.items.map((item: ItemType) => ({ ...item, edit: false })))
    }
    fetchItems()
  }, [])

  return (
    <List>
      {app.openNewItemForm && <New />}
      {items.map((item) => (
        <Item key={item.id} item={item} />
      ))}
    </List>
  )
}
```

`item.json`を非同期で取得したものを`setItemsState`でセットして`Item`コンポーネントが表示される

### 検索できるようにする

```jsx:src/componets/Header.tsx
import React from 'react'
import { useRecoilState } from 'recoil'
import AppBar from '@material-ui/core/AppBar'
import Toolbar from '@material-ui/core/Toolbar'
import InputBase from '@material-ui/core/InputBase'
import { fade, makeStyles } from '@material-ui/core/styles'
import SearchIcon from '@material-ui/icons/Search'
import { appState } from '../atoms/app'

const useStyles = makeStyles((theme) => ({
  root: {
    flexGrow: 1,
  },
  menuButton: {
    marginRight: theme.spacing(2),
  },
  title: {
    flexGrow: 1,
    display: 'none',
    [theme.breakpoints.up('sm')]: {
      display: 'block',
    },
  },
  search: {
    position: 'relative',
    borderRadius: theme.shape.borderRadius,
    backgroundColor: fade(theme.palette.common.white, 0.15),
    '&:hover': {
      backgroundColor: fade(theme.palette.common.white, 0.25),
    },
    marginLeft: 0,
    width: '100%',
    [theme.breakpoints.up('sm')]: {
      marginLeft: theme.spacing(1),
      width: 'auto',
    },
  },
  searchIcon: {
    padding: theme.spacing(0, 2),
    height: '100%',
    position: 'absolute',
    pointerEvents: 'none',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  },
  inputRoot: {
    color: 'inherit',
  },
  inputInput: {
    padding: theme.spacing(1, 1, 1, 0),
    paddingLeft: `calc(1em + ${theme.spacing(4)}px)`,
    transition: theme.transitions.create('width'),
    width: '100%',
    [theme.breakpoints.up('sm')]: {
      width: '12ch',
      '&:focus': {
        width: '20ch',
      },
    },
  },
}))

export const Header: React.FC = () => {
  const classes = useStyles()

  const [app, setAppState] = useRecoilState(appState)

  return (
    <div className={classes.root}>
      <AppBar position="static">
        <Toolbar>
          <div className={classes.search}>
            <div className={classes.searchIcon}>
              <SearchIcon />
            </div>
            <InputBase
              classes={{ root: classes.inputRoot, input: classes.inputInput }}
              inputProps={{ 'aria-label': 'search' }}
              onChange={(event) => setAppState({ ...app, search: event.target.value })}
            />
          </div>
        </Toolbar>
      </AppBar>
    </div>
  )
}
```

```jsx:src/components/Items.tsx
import React, { useEffect } from 'react'
import List from '@material-ui/core/List'
import { useSetRecoilState, useRecoilValue, selector } from 'recoil'
import { itemsState, Item as ItemType } from '../atoms/items'
import { appState } from '../atoms/app'
import { Item } from './Item'
import { New } from './Item/New'

const filteredItemsState = selector({
  key: 'filteredItems',
  get: ({ get }) =>
    get(itemsState).filter(
      (item) => item.title.indexOf(get(appState).search) > -1 || item.body.indexOf(get(appState).search) > -1
    ),
})

export const Items: React.FC = () => {
  const setItemsState = useSetRecoilState(itemsState)
  const app = useRecoilValue(appState)
  const filteredItems = useRecoilValue(filteredItemsState)

  useEffect(() => {
    async function fetchItems(): Promise<void> {
      const response = await fetch(process.env.API_ENDPOINT ? `${process.env.API_ENDPOINT}/items.json` : './items.json')
      const json = await response.json()
      setItemsState(() => json.items.map((item: ItemType) => ({ ...item, edit: false })))
    }
    fetchItems()
  }, [])

  return (
    <List>
      {app.openNewItemForm && <New />}
      {filteredItems.map((item) => (
        <Item key={item.id} item={item} />
      ))}
    </List>
  )
}
```

1. `Header`コンポーネントで検索ワードを入力されたら、`setAppState`で保存を行う
1. ` filteredItemsState`で`selector`の`get`で検索ワードに合わない条件のものをフィルターした状態で`Item`コンポーネントが表示される

## 追加

```jsx:src/components/Item/New.tsx
import React, { useState } from 'react'
import { v4 as uuid } from 'uuid'
import ListItem from '@material-ui/core/ListItem'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import IconButton from '@material-ui/core/IconButton'
import SaveIcon from '@material-ui/icons/Save'
import TextField from '@material-ui/core/TextField'
import { useRecoilState } from 'recoil'
import { ItemForm } from '../../atoms/items'
import { itemsState } from '../../atoms/items'
import { appState } from '../../atoms/app'

export const New: React.FC = () => {
  const [items, setItemsState] = useRecoilState(itemsState)
  const [app, setAppState] = useRecoilState(appState)
  const [item, setItem] = useState<ItemForm>({ id: uuid(), title: '', body: '', edit: true })

  return (
    <ListItem>
      <TextField
        label="title"
        type="text"
        value={item.title}
        onChange={(event) => setItem({ ...item, title: event.target.value })}
      />
      <TextField
        label="body"
        style={{ margin: 8 }}
        type="text"
        value={item.body}
        onChange={(event) => setItem({ ...item, body: event.target.value })}
      />
      <ListItemSecondaryAction>
        {item.title && item.body && (
          <IconButton
            edge="end"
            onClick={() => {
              setItemsState([{ ...item, edit: false }, ...items])
              setAppState({ ...app, openNewItemForm: false })
            }}
          >
            <SaveIcon />
          </IconButton>
        )}
      </ListItemSecondaryAction>
    </ListItem>
  )
}
```

1. 各項目の入力された値を`setItem`で保持する
1. `SaveIcon`の`IconButton`がクリックされた時に`setItemsState`で編集してる`Item`を`setItem`で保持してる値に入れ替える

## 更新

```jsx:src/components/Item/Edit.tsx
import React, { useState } from 'react'
import ListItem from '@material-ui/core/ListItem'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import IconButton from '@material-ui/core/IconButton'
import SaveIcon from '@material-ui/icons/Save'
import CancelIcon from '@material-ui/icons/Cancel'
import TextField from '@material-ui/core/TextField'
import { useRecoilState } from 'recoil'
import { Item as ItemType } from '../../atoms/items'
import { itemsState } from '../../atoms/items'

type Props = {
  item: ItemType
}

export const Edit: React.FC<Props> = (props) => {
  const [items, setItemsState] = useRecoilState(itemsState)
  const [item, setItem] = useState<ItemType>(props.item)

  return (
    <ListItem>
      <TextField
        label="title"
        type="text"
        value={item.title}
        onChange={(event) => setItem({ ...item, title: event.target.value })}
      />
      <TextField
        label="body"
        style={{ margin: 8 }}
        type="text"
        value={item.body}
        onChange={(event) => setItem({ ...item, body: event.target.value })}
      />
      <ListItemSecondaryAction>
        <IconButton
          edge="end"
          onClick={() => setItemsState(items.map((i) => (i.id === props.item.id ? { ...item, edit: false } : i)))}
        >
          <SaveIcon />
        </IconButton>
        <IconButton
          edge="end"
          onClick={() => setItemsState(items.map((i) => (i.id === props.item.id ? { ...props.item, edit: false } : i)))}
        >
          <CancelIcon />
        </IconButton>
      </ListItemSecondaryAction>
    </ListItem>
  )
}
```
1. 各項目の入力された値を`setItem`で保持する
1. `SaveIcon`の`IconButton`がクリックされた時に`setItemsState`で編集してる`Item`を`setItem`で保持してる値と編集のUIを閉じるために`edit`を`false`にして入れ替える

## 削除

```jsx:src/components/Item/Show.tsx
import React from 'react'
import { useRecoilState } from 'recoil'
import ListItem from '@material-ui/core/ListItem'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText'
import IconButton from '@material-ui/core/IconButton'
import DeleteIcon from '@material-ui/icons/Delete'
import EditIcon from '@material-ui/icons/Edit'
import { Item } from '../../atoms/items'
import { itemsState } from '../../atoms/items'

type Props = {
  item: Item
}

export const Show: React.FC<Props> = (props) => {
  const [items, setItemsState] = useRecoilState(itemsState)

  return (
    <ListItem>
      <ListItemText primary={props.item.title} secondary={props.item.body} />
      <ListItemSecondaryAction>
        <IconButton
          edge="end"
          onClick={() => setItemsState(items.map((item) => (item.id === props.item.id ? { ...item, edit: true } : item)))}
        >
          <EditIcon />
        </IconButton>
        <IconButton
          edge="end"
          onClick={() => {
            if (confirm(`Are you sure? ${props.item.title}`)) {
              setItemsState(items.filter((item) => item.id !== props.item.id))
            }
          }}
        >
          <DeleteIcon />
        </IconButton>
      </ListItemSecondaryAction>
    </ListItem>
  )
}
```

 `DeleteIcon`の`IconButton`をクリックした時に`item`を`filter`で取り除いた配列を`setItemsState`でセットする