62
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NRI OpenStandiaAdvent Calendar 2023

Day 3

初学者でも分かるようにJotaiを丁寧に解説していく

Last updated at Posted at 2023-12-02

1.はじめに

React初学者が辛いものと言えば、状態管理です。
そんな状態管理を簡単にしてくれるものにReduxやRecoil、Jotaiなどの状態管理ライブラリがあります。
私も社内の研修でJotaiを触ってみる機会があったのですが、状態管理ライブラリを触ったことも無いReact初学者には理解が難しい概念が多いように感じました。

そこで本記事ではReduxなどの有名な状態管理ライブラリに触れたことがないReact初学者でも状態管理ライブラリを扱うことができることを目標に、ライブラリの中で最もシンプル(主観)なJotaiについてリファレンスをベースにできるだけ丁寧に解説していきたいと思います。
これからJotaiに初めて触れるエンジニアのお役に立てれば幸いです。

1.1.対象読者

  • React初学者で使い慣れてきたが、状態管理に困ってきた人
  • 状態管理ライブラリを触ってみたいけど、何を使えばいいか分からない人
  • Jotaiに興味がある人

1.2.環境情報

本記事は以下の環境で実行しています。

環境 バージョン
node 18.17.0
npm 9.6.7
jotai 2.5.0

2.状態管理ライブラリとJotai

JotaiはReactの状態管理ライブラリの1つです。
Reactでは規模が大きいアプリケーションを開発する際に、多くの状態管理が必要になり、結果的に状態の制御が難しくなって開発効率が低下してしまうことがあります。

この問題を解決するのが、Reactの状態管理ライブラリです。
状態管理ライブラリでは、一般的に次のことができるものがほとんどです。

  • グローバルに状態を管理する
  • Propsのバケツリレーを少なくする
  • 不要な再レンダリングを防止する

2.1.Jotaiの特徴

Jotaiは以下の特徴を持っています。

  • シンプルかつ柔軟に開発することできる
  • 軽量なAPI
  • TypeScriptで開発されている

初学者にとって最もJotaiが便利だと感じる場面は「グローバルステートとして状態をシンプルに管理できる」という点にあると思います。
JotaiはReduxやRecoilと異なり、キーの設定も必要なく、使い方も非常にシンプルです。
学習コストが低いのは初学者にとって大きなメリットではないかと思います。

2.2.Jotai Core API

ここからはJotaiで必ず使用する主要なAPIを4つ解説していきます。
この4つのAPIを理解することが、Jotaiを使いこなす一歩に繋がります。
それぞれAPIをどのように実装するのかコードも添えて説明をしているので、コードを読みながら進めてみてください。

2.2.1.atom

atomとは状態管理のための最小単位です。
Jotaiにおいてatomを宣言する場合はatom(initial value)のように初期値を定義して宣言します。
宣言されたatomはグローバルステートとして状態を持つことができます。

import { atom } from 'jotai'

const priceAtom = atom(10)
const messageAtom = atom('hello')
const productAtom = atom({ id: 12, name: 'good stuff' })

またJotai特有の概念として、atomからatomを生成するderived atomがあります。
既存のatomから他のatomを参照することができるので、状態のロジックをatom内で隠蔽でき、コードの可読性にも繋がります。
atomの値はgetを用いることで参照することができ、setを用いることで値の代入ができます。

// priceAtomの値段を2倍にして読み取る
const doublePriceAtom = atom((get) => get(priceAtom) * 2)

// priceAtomから引数で受け取ったdiscountを減算した値を書き込む
const discountPriceAtom = atom(
  null, // wirteOnlyなのでnullに設定
  (get, set, discount) => {
    set(priceAtom, get(priceAtom) - discount)
  },
)
// praiceAtomを2倍にして読み取り、newPriceの1/2を書き込む
const readWriteAtom = atom(
  (get) => get(priceAtom) * 2,
  (get, set, newPrice) => {
    set(priceAtom, newPrice / 2)
  },
)

ここで説明したderived atomの使用方法は、2.2.2で説明するuseAtomで詳しく解説するので、コードを理解することができれば問題ないです。

2.2.2.useAtom

useAtomはatomが持っている定義された状態を読み込んで状態管理を行う変数にセットします。
イメージはReactのuseStateとほとんど同じです。

const [value, setValue] = useAtom(atom)

上記コードの動作は、atomのread関数によってvalueに値がセットされます。
またsetValueが使用される場合にはatomのwrite関数によって値が更新されます。

const priceAtom = atom(100)
const addingPriceAtom = atom(
  (get) => get(priceAtom) / 2,
  (get, set, num: number) => {
    set(priceAtom, get(priceAtom) * num)
  },
)
// valueには50がセットされ、setValue(x)でvalueの値をx倍する
const [value, setValue] = useAtom(addingPriceAtom)

文章だけでは分かりにくいと思うので下記のプログラムを参照してください。
初期値として100円が表示されていて、ボタンを押すごとに100円ずつ加算されるプログラムです。

// 初期値を宣言
const priceAtom = atom(100)
// priceAtomからread/writeな派生atomを宣言
const addingPriceAtom = atom(
  (get) => get(priceAtom), // read関数ではそのままpriceAtomの値を返す
  (get, set, num: number) => {
    // write関数では与えられた値を加算する
    set(priceAtom, get(priceAtom) + num)
  },
)

const App = () => {
  // read関数で読み込まれた値がpriceにセットされる
  const [price, addPrice] = useAtom(addingPriceAtom)
  return (
    <div>
      <div>{price} yen</div>
      // ボタンを押された場合はaddingPriceAtomの第3引数に100がセットされる
      <button type="button" onClick={() => addPrice(100)}>
        Add 100 yen
      </button>
    </div>
  )
}

export default App

useAtom.gif

2.2.3.Store

Storeは共有するデータの保管場所を定義するものです。
createStoreを使用することで、新しい空のストアを作成することができます。
Storeは下記の3つのメソッドをを持っています。

  • get : atomの値を取得する
  • set : atomの値を設定する
  • sub : atomの値を更新する

このStoreは後述するProviderに渡すために使用します。

// 空のstoreの宣言
const myStore = createStore()

const countAtom = atom(0)
// 宣言したstoreにcountAtomに初期値として1を代入した状態でセットする
myStore.set(countAtom, 1)

const Root = () => (
  <Provider store={myStore}>
    <App />
  </Provider>
)

2.2.4.Provider

Providerを使用することで、Providerに囲われているコンポーネントだけに閉じられたatomを提供することができます。
つまり、コンポーネントツリー毎に異なるatomを保持する必要がある場合は、ProviderStoreを使うことで空間的に切り分けられた環境でatomを使用することができます。
メリットとしては以下の通りです。

  • ツリー毎に異なる状態を持つことができる
  • 切り分けた空間毎に異なる初期値を持つatomを使用することができる

下記はProviderについて視覚的に理解できるカウンタープログラムです。
ProviderとStoreを用いることで、countAtomの値が0と100の異なる初期値で初期化されていることが確認できます。
また、Provider内の状態とグローバルステート内の状態は異なる値を保持することも確認できます。

import './App.css'
import { Provider, atom, createStore, useAtom } from 'jotai'

// グローバルステート
const countAtom = atom(0)
// storeを宣言
const myStore = createStore()
// storeの中にcountAtomを初期値100としてセットする
myStore.set(countAtom, 100)

// ボタンを押すと+1されるカウンター
const Counter = () => {
  const [count,setCount] = useAtom(countAtom);
  return (
  <>
    <div>{count}</div>
    <button onClick={ () => setCount((p) => p+1) }>
      +1するボタン
    </button>
  </>
  )
}

const App = () => (
  <>
    <Provider store={myStore}>
      <h1>Proverで切り分けした空間</h1>
      <Counter />
      <Counter />               
    </Provider>
    
    <h1>グローバルステート空間</h1>
    <Counter />
  </>
)

export default App

Provider.gif

また、JotaiはProviderレスでも使用することができます。
その場合は、全てのatomがグローバルステートとして扱われます。

3.Todoアプリを作ってみる

Jotaiの基礎が理解できたところで、早速Todoアプリを作成してみようと思います。
理由は単純で、新しい技術の入門をする時は、Todoアプリのような簡単なものを作ってみると理解が進みやすいと感じているからです。

JotaiチュートリアルにもTodoの作成とフィルタリングができるアプリがあるので、それを模倣しながらJotaiを活用したコーディングの手順を解説していきたいと思います。

最終的にできるものは下記のようなTodoアプリになります。
それぞれのTodoはCompletedまたはIncompletedの状態を持ち、ラジオボタンによってタスク全体がフィルタリングされる仕様です。

TodoApp.gif

3.1環境構築

今回はVite+React+TypeScript+Jotaiで開発をしていきたいと思います。
Viteを使うことで簡単にReactの環境構築をすることができます。

コマンド実行後に下記のような画面が出れば、環境構築は完了です。

npm create vite@latest jotai-todoApp -- --template react-ts
cd jotai-todoApp
npm run dev

image.png

Todoアプリの作成で必要なパッケージもインストールしておきましょう。

npm install jotai
npm install antd
npm install react-spring

3.2.実装手順

実際に私が実装した時の手順に沿って解説していきたいと思います。

3.2.1.コンポーネント設計

まずは画面を機能ごとに区切って各コンポーネントの役割を定義します。
今回は下記のように3つのコンポーネントを作成することにしました。

  • フィルターする値を選択するコンポーネント(赤枠)
  • タスクコンポーネント(緑枠)
  • タスクを入力 + 各コンポーネントを取りまとめるコンポーネント(黄枠)

image.png

3.2.2.グローバルステートを定義

コンポーネントの設計が終わったら、次にグローバルステートを宣言していきたいと思います。
このタイミングで想定できる範囲内のグローバルステートを宣言しておくことで、無駄なステートを増やさないことが狙いです。
今後コーディングしていく中で、ステートの宣言が必要な場合は、その都度atom.tsに記載していく方針で良いと思います。

今回は、別ファイルにまとめて状態管理を行う変数を宣言しておき、必要な時に各コンポーネントから呼び出して使用する設計にしています。

atom.ts
import { atom } from 'jotai'
import type { PrimitiveAtom } from 'jotai'

type Todo = {
  title: string
  completed: boolean
}

// 現在指定されているfilterを管理するatom(赤枠)
export const filterAtom = atom('all')

// todoリストの各todoを管理するatom
export const todosAtom = atom<PrimitiveAtom<Todo>[]>([])

// filter後のtodoを管理するatom
export const filteredAtom = atom<PrimitiveAtom<Todo>[]>((get) => {
  // 現在のfilterを読み込む
  const filter = get(filterAtom)
  // 現在のtodosを読み込む
  const todos = get(todosAtom)
  
  // filterがallの場合は全てのtodosを返す
  if (filter === 'all') return todos
  // filterがcompletedの時は todosの各atomがcompletedなものを返す
  if (filter === 'completed') return todos.filter((atom) => get(atom).completed)
  // todosの各atomがIncompletedなものを返す
  return todos.filter((atom) => !get(atom).completed)
})

3.2.3.Filterコンポーネントの実装(赤枠)

グローバルステートの実装を終えたので、コンポーネントの実装に進んでいきます。
まずは他のコンポーネントに影響を与えやすいTodoアプリのFilterコンポーネントを実装していきます。
filterの状態管理にはatom.tsで宣言したfilterAtomを使用します。

Filter.tsx
import { useAtom } from 'jotai'
import { Radio } from 'antd'

import { filterAtom } from '../atoms'

const Filter = () => {
 // filterAtomをfilterに代入
  const [filter, setFilter] = useAtom(filterAtom)
  return (
  // 各ラジオボタンが押されるたびにsetFilterで値をFilterにセットする
    <Radio.Group onChange={(e) => setFilter(e.target.value as string)} value={filter}>
      <Radio value="all">All</Radio>
      <Radio value="completed">Completed</Radio>
      <Radio value="incompleted">Incompleted</Radio>
    </Radio.Group>
  )
}

export default Filter

3.2.4.TodoItemコンポーネントの実装(緑枠)

Filterコンポーネントの実装を終えたので、次にTodoを表示するTodoItemコンポーネント部分を実装していきます。

TodoItem.tsx
import { useAtom } from 'jotai'
import { CloseOutlined } from '@ant-design/icons'

// todoが持つ状態の型を定義
type Todo = {
  title: string
  completed: boolean
}

// todoを消去する関数の型を宣言
type RemoveFn = (item: PrimitiveAtom<Todo>) => void

// TodoItemは状態とatom自身を消去する型を持つ
type TodoItemProps = {
  atom: PrimitiveAtom<Todo>
  remove: RemoveFn
}

const TodoItem = ({ atom, remove }: TodoItemProps) => {
 // 引数で与えられたatomをitemに格納
  const [item, setItem] = useAtom(atom)
  // 元のtodoからcompletedの値だけを逆にする
  const toggleCompleted = () => setItem((todo) => ({ ...todo, completed: !todo.completed }))
  return (
    <>
      // チェックボックスの描画
      <input type="checkbox" checked={item.completed} onChange={toggleCompleted} />
      
      // completedの値によって取り消し線を描画
      <span style={{ textDecoration: item.completed ? 'line-through' : '' }}>{item.title}</span>

      // 消去ボタン。UIにはAnt Designを使用
      <CloseOutlined onClick={() => remove(atom)} />
    </>
  )
}

export default TodoItem

3.2.5.Filteredコンポーネントの実装(緑枠)

FilterコンポーネントとTodoItemコンポーネントの実装が終わったので、フィルタリングされたtodoを表示するFilteredコンポーネントを実装していきます。

Filtered.tsx
import { useAtom } from 'jotai'
import { a, useTransition } from 'react-spring'

import { filteredAtom } from '../atoms'
import FilteredType from '../types/filtered'

import TodoItem from './TodoItem'

const Filtered = (props: FilteredType) => {
  // 現在のfilterを考慮して表示するべきtodosを格納
  const [todos] = useAtom(filteredAtom)
  // react-springの機能。動きを持たせてくれる。
  const transitions = useTransition(todos, {
    keys: (todo) => todo.toString(),
    from: { opacity: 0, height: 0 },
    enter: { opacity: 1, height: 40 },
    leave: { opacity: 0, height: 0 },
  })
  return transitions((style, atom) => (
    <a.div className="item" style={style}>
      <TodoItem atom={atom} {...props} />
    </a.div>
  ))
}

export default Filtered

ここでは、filteredAtomを呼び出すわけですが、ここで以前に定義したFilterの状態をまとめて考慮して値をフィルタすることができます。
状態管理ライブラリを使わない場合は、グローバルステートの概念が無いので親コンポーネントにFilterの状態とtodosの状態を管理させて、Filteredコンポーネントを子コンポーネントとしてProps渡しを行うといったように、コンポーネント構造と状態管理の両方を考慮しながら実装をする必要があります。
グローバルステートを簡単に実現して、状態を一元管理できるJotai最高ですね。

3.2.5.TodoListコンポーネントの実装(黄枠)

各コンポーネントの作成を終えたので、最後に今までのコンポーネントをまとめるTodoListコンポーネントを実装します。

TodoList.tsx
import { atom, useAtom } from 'jotai'
import { FormEvent } from 'react'

import { todosAtom } from '../atoms'
import RemoveFn from '../types/removeFn'
import Todo from '../types/todo'

import Filter from './Filter'
import Filtered from './Filtered'

const TodoList = () => {
  //todosAtomに値をセットする関数のみ宣言する
  const [, setTodos] = useAtom(todosAtom)
  
  // 引数で受け取ったtodo以外のtodoをtodosにセットする
  const remove: RemoveFn = (todo) => setTodos((prev) => prev.filter((item) => item !== todo))
  
  const add = (e: FormEvent<HTMLFormElement>) => {
    // submitイベントが現在のURLに対してフォームを送信することをキャンセルする
    e.preventDefault()
    
    // 入力を受け取る
    const title = e.currentTarget.inputTitle.value as string
    
    // 入力欄を空にする
    e.currentTarget.inputTitle.value = ''
    
    // 配列todosAtomに受け取った入力と状態(Incompleted)を格納する
    setTodos((prev) => [...prev, atom<Todo>({ title, completed: false })])
  }
  return (
    <form onSubmit={add}>
      <Filter />
      <input name="inputTitle" placeholder="Type ..." />
      <Filtered remove={remove} />
    </form>
  )
}

export default TodoList

3.2.6.App.tsxの実装

今回はProviderを使用していますが、todoアプリ内で状態を区切る必要は無いので、Providerを使用しなくても問題ないです。
以上の実装でTodoアプリが無事完成になります。

App.tsx
import { Provider } from 'jotai'

import './App.css'
import TodoList from './components/TodoList'

const App = () => (
  <Provider>
    <h1>Jōtai Todo Application</h1>
    <TodoList />
  </Provider>
)

export default App

4.おわりに

この記事では状態管理ライブラリのJotaiについて、初学者がすぐに扱えるようになることを目標にご紹介しました。

今回はチュートリアルになぞってtodoアプリを作ったので次はもっと規模の大きいアプリをJotaiで作成したみたいです。
Jotaiはこれからもアップデートが行わていく気配があるので、今後も注目していきたいと思いました。

この記事が皆さんの少しでも役に立ったなら、とても嬉しいです。
最後まで読んで頂き、ありがとうございました。

5.参考

62
25
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
62
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?