13
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

はじめに

Recoilを使って表示、検索、編集を実装してみた。

demo.gif

State

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
    追加するフォームが開いてるかどうかの条件を保存するのに使う

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が編集フォームになっているかの状態の保存に使う

表示

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" }
  ]
}
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コンポーネントが表示される

検索できるようにする

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>
  )
}
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で保存を行う
  2. filteredItemsStateselectorgetで検索ワードに合わない条件のものをフィルターした状態でItemコンポーネントが表示される

追加

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で保持する
  2. SaveIconIconButtonがクリックされた時にsetItemsStateで編集してるItemsetItemで保持してる値に入れ替える

更新

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で保持する
  2. SaveIconIconButtonがクリックされた時にsetItemsStateで編集してるItemsetItemで保持してる値と編集のUIを閉じるためにeditfalseにして入れ替える

削除

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

DeleteIconIconButtonをクリックした時にitemfilterで取り除いた配列をsetItemsStateでセットする

13
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?