🔄

Firestoreとjotaiでリアルタイムにデータを同期する

2023/01/24に公開

この記事では簡単なPostアプリのCRUD操作をjotaiとFirebaseのCloud Firestoreを使って実現しています。
FirestoreのonSnapshotとjotaiを使って、クライアント間でデータをリアルタイムに同期するプログラムを書きました。

firestore-jotai-realtime-sync

Firestoreやjotaiに入門したばかりなので、書き方を間違えている可能性があります。参考程度に見ていただければ幸いです。

環境設定

以下のライブラリ&フレームワークを使用して構築しました

"@chakra-ui/icons": "^2.0.16",
"@chakra-ui/react": "^2.4.9",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"firebase": "^9.15.0",
"framer-motion": "^8.4.6",
"jotai": "^1.13.1",
"next": "13.1.1",
"react": "18.2.0",
"react-dom": "18.2.0"

Chakra UIはCSSを書くのが面倒だったので使用しました。この記事ではスタイリングに関しては触れるつもりがありません。TailwindでもMUIでも根本的なロジックは変わりません。好きな方法でjson色つけを楽しんでください。

jotaiはバージョン1.13.1ですが、内部ではバージョン2のPre-releaseを使用しています。もし書き方に疑問を感じたら公式のMigrationガイドを参照してください。私もjotaiV2を書き始めたばかりなので間違っているかもしれません。

Next.jsはバージョン13を使用していますが、App Directoryは使っていません(そもそもChakra UIがClient Sideでしか動作しません)。

ファイル構成は以下のようになっています。Next.jsのプロジェクトルートにsrcディレクトリを作り、描画と状態管理に必要なコードの全てをsrc/components/Posts以下に入れています。

src
├── components
│  └── Posts
│     ├── AddPost.tsx
│     ├── index.tsx
│     ├── Post.tsx
│     ├── PostEditor.tsx
│     └── usePosts.ts
├── pages
│  ├── _app.tsx
│  └── index.tsx

コード解説

ここからは実際のコードを紹介します。

Firebaseの初期化とAtomの宣言(usePosts.ts)

このコードではFirebaseのInitializeとjotaiの初期化、postのCreate, Update, Deleteをそれぞれ行うatomの定義、現在選択中のpostを管理するためのatomの定義をしています。

usePosts.ts
import { initializeApp } from 'firebase/app';
import {
  addDoc,
  collection,
  deleteDoc,
  doc,
  getFirestore,
  onSnapshot,
  updateDoc,
} from 'firebase/firestore';
import { useSetAtom } from 'jotai/react';
import { atom, PrimitiveAtom } from 'jotai/vanilla';
import { splitAtom } from 'jotai/vanilla/utils';
import { useEffect } from 'react';

export type PostType = {
  id: string;
  title: string;
  content: string;
};

// TODO: Replace the following with your app's Firebase project configuration
// See: https://firebase.google.com/docs/web/learn-more#config-object
const firebaseConfig = {
  // ...
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);

// Initialize Cloud Firestore and get a reference to the service
const db = getFirestore(app);
const collectionRef = collection(db, 'posts');

export const postsAtom = atom<PostType[]>([]);
export const postAtomsAtom = splitAtom(postsAtom);

export const usePosts = () => {
  const setPosts = useSetAtom(postsAtom);
  useEffect(() => {
    const unSubscriber = onSnapshot(collectionRef, (docSnapshot) => {
      const initialPosts = docSnapshot.docs.map((post) => {
        return {
          ...structuredClone(post.data()),
          id: post.id,
        } as PostType;
      });

      setPosts(initialPosts);
    });

    return () => {
      unSubscriber();
    };
  }, [setPosts]);
};

export const titleAtom = atom('');
export const contentAtom = atom('');
export const createPostAtom = atom(
  (get) =>
    !!get(titleAtom) && !!get(contentAtom) && get(selectedPostAtom) === null,
  async (get, set) => {
    const title = get(titleAtom);
    const content = get(contentAtom);
    if (title && content) {
      const docRef = await addDoc(collectionRef, {
        title,
        content,
      });

      set(titleAtom, '');
      set(contentAtom, '');
    }
  }
);

const baseSelectedPostAtom = atom<PrimitiveAtom<PostType> | null>(null);
export const selectedPostAtom = atom(
  (get) => get(baseSelectedPostAtom),
  (get, set, postItemAtom: PrimitiveAtom<PostType> | null) => {
    set(baseSelectedPostAtom, postItemAtom);
    if (postItemAtom) {
      const postItem = get(postItemAtom);
      set(titleAtom, postItem.title);
      set(contentAtom, postItem.content);
    }
  }
);

export const updatePostAtom = atom(
  (get) => !!get(titleAtom) && !!get(contentAtom) && !!get(selectedPostAtom),
  async (get, set) => {
    const title = get(titleAtom);
    const content = get(contentAtom);
    const selected = get(selectedPostAtom);
    if (title && content && selected) {
      const selectedPost = get(selected);
      await updateDoc(doc(collectionRef, selectedPost.id), {
        title,
        content,
      });
      set(titleAtom, '');
      set(contentAtom, '');
    }
  }
);

export const deletePostAtom = atom(
  (get) => !!get(selectedPostAtom),
  async (get, set) => {
    const selected = get(selectedPostAtom);
    if (selected) {
      const selectedPost = get(selected);
      await deleteDoc(doc(collectionRef, selectedPost.id));
    }
  }
);

splitAtomはjotaiでリストを扱う際に便利な関数です。これを使うと、リストのAtomリストの要素のアトムのリストに変換してくれます(なるほど、分からん)。
型で言ったほうが分かりやすいかもしれません。postsAtomはPrimitiveAtom<PostType[]>で表されます。postAtomsAtomはWritableAtom<PrimitiveAtom<PostType>[]で表されます。これを使うことで、リストの要素にアクセスしやすくなります。

export const postsAtom = atom<PostType[]>([]);
export const postAtomsAtom = splitAtom(postsAtom);

以下の部分でFirestoreのデータをjotaiに格納しています。

export const usePosts = () => {
  const setPosts = useSetAtom(postsAtom);
  useEffect(() => {
    const unSubscriber = onSnapshot(collectionRef, (docSnapshot) => {
      const initialPosts = docSnapshot.docs.map((post) => {
        return {
          ...structuredClone(post.data()),
          id: post.id,
        } as PostType;
      });

      setPosts(initialPosts);
    });

    return () => {
      unSubscriber();
    };
  }, [setPosts]);
};

firestore.onSnapshotのコールバックの中でjotaiのuseSetAtomを呼び出し、firestoreがデータの変更を検出するたびにjotaiのデータを書き換えています。

createPostAtom,updatePostAtom,deletePostAtomはそれぞれのset関数の中でfirestoreのaddDoc,updateDoc,deleteDocを呼んでいます。

コンポーネント全体の描画(index.tsx)

以下はPostsコンポーネントのコードです。

src/components/Posts/index.tsx
import { Heading, useDisclosure, VStack } from '@chakra-ui/react';
import { useAtom } from 'jotai/react';
import { postAtomsAtom, usePosts } from './usePosts';

import AddPost from './AddPost';
import Post from './Post';
import PostEditor from './PostEditor';

const Posts = () => {
  const [postAtoms] = useAtom(postAtomsAtom);
  const { isOpen, onOpen, onClose } = useDisclosure();
  usePosts();

  return (
    <>
      <Heading fontSize={'4xl'} marginBottom="3">
        Posts
      </Heading>
      <VStack alignItems="stretch" gap={'4'}>
        {postAtoms.map((postAtom) => {
          return (
            <Post key={`${postAtom}`} postAtom={postAtom} onOpen={onOpen} />
          );
        })}
      </VStack>
      <PostEditor isOpen={isOpen} onClose={onClose} />
      <AddPost onOpen={onOpen} />
    </>
  );
};

export default Posts;

usePostsでコンポーネントのマウント時にFirestoreの初期化を行います。
後は描画やデータの追加、編集に必要なコンポーネントを書きます。

Postの描画(Post.tsx)

Post.tsx
import { useAtom } from 'jotai/react';
import { atom, PrimitiveAtom } from 'jotai/vanilla';
import React, { useMemo } from 'react';
import { deletePostAtom, PostType, selectedPostAtom } from './usePosts';

import {
  Box,
  Button,
  Card,
  CardBody,
  CardHeader,
  Divider,
  Heading,
  HStack,
  Stack,
  Text,
} from '@chakra-ui/react';

const Post = ({
  postAtom,
  onOpen,
}: {
  postAtom: PrimitiveAtom<PostType>;
  onOpen: () => void;
}) => {
  const [post] = useAtom(postAtom);
  const [, deletePost] = useAtom(deletePostAtom);
  const [, setSelected] = useAtom(
    useMemo(
      () =>
        atom(
          (get) => get(selectedPostAtom) === postAtom,
          (_get, set) => set(selectedPostAtom, postAtom)
        ),
      [postAtom]
    )
  );

  return (
    <Card paddingX="5" paddingY="7" shadow={'xl'}>
      <CardHeader>
        <Heading as="h2" fontSize="3xl">
          {post.title}
        </Heading>
      </CardHeader>
      <CardBody>
        <Stack spacing="4">
          <Box>
            <Text>{post.content}</Text>
          </Box>
          <Divider />
          <HStack justifyContent={'space-between'}>
            <Button
              onClick={() => {
                setSelected();
                deletePost();
              }}
              shadow="lg"
            >
              Delete
            </Button>
            <Button
              onClick={() => {
                setSelected();
                onOpen();
              }}
              shadow="lg"
            >
              Edit
            </Button>
          </HStack>
        </Stack>
      </CardBody>
    </Card>
  );
};

export default Post;

このコンポーネントで一番よく分からないのはここだと思うので説明します。

  const [, setSelected] = useAtom(
    useMemo(
      () =>
        atom(
          (get) => get(selectedPostAtom) === postAtom,
          (_get, set) => set(selectedPostAtom, postAtom)
        ),
      [postAtom]
    )
  );

これは今のpostの選択状態を取得、設定するAtomです。useMemoを挟んでいるのはレンダリングの度にこれを計算させる意味が特に無いので、postAtomが変更されるまで値を保持してもらっています。このコードではAtomのget関数に当たる部分を使っていませんが、以下のようにこの記事が選択状態なのかどうかを判別してスタイルを変更したり、ボタンをDisableしたりEnableしたりするのに使えます。

  const [isSelected, setSelected] = useAtom(...)
  // ...
  return (
    <>
      <div style={{backgroundColor: isSelected ? "gray" : "inherit" }}></div>
    </>
  )

こちらの記事を参考にして書きました

https://zenn.dev/tell_y/articles/4216470d4f5254#リスト機能

postの追加(AddPost.tsx)

以下はpostを追加するためのフローティングボタンです。

AddPost.tsx
import { AddIcon } from '@chakra-ui/icons';
import { Box, IconButton } from '@chakra-ui/react';
import { useAtom } from 'jotai/react';
import { contentAtom, selectedPostAtom, titleAtom } from './usePosts';

const AddPost = ({ onOpen }: { onOpen: () => void }) => {
  const [, setSelectedPost] = useAtom(selectedPostAtom);
  const [, setTitle] = useAtom(titleAtom);
  const [, setContent] = useAtom(contentAtom);

  return (
    <Box
      position="fixed"
      bottom={'20px'}
      right={['16px', '84px']}
      zIndex={2}
      rounded="full"
    >
      <IconButton
        aria-label="Add post"
        boxShadow={'xl'}
        shadow="dark-lg"
        colorScheme={'white'}
        backgroundColor="red.500"
        size="lg"
        rounded={'full'}
        icon={<AddIcon boxSize={'6'} />}
        onClick={() => {
          setTitle('');
          setContent('');
          setSelectedPost(null);
          onOpen();
        }}
      />
    </Box>
  );
};

export default AddPost;

ボタンを押すと、postを編集するためのDrawerを表示します。新規追加なので、selectedPostnullに設定します。

postの編集Drawer(PostEditor.tsx)

postを編集するためのDrawerです。

PostEditor.tsx
import {
  contentAtom,
  createPostAtom,
  selectedPostAtom,
  titleAtom,
  updatePostAtom,
} from './usePosts';

import {
  Button,
  Container,
  Drawer,
  DrawerBody,
  DrawerCloseButton,
  DrawerContent,
  DrawerFooter,
  DrawerHeader,
  DrawerOverlay,
  FormControl,
  FormLabel,
  Input,
  Textarea,
} from '@chakra-ui/react';
import { useAtom } from 'jotai/react';

const PostEditor = ({
  isOpen,
  onClose,
}: {
  isOpen: boolean;
  onClose: () => void;
}) => {
  const [enableCreate, createPost] = useAtom(createPostAtom);
  const [enableUpdate, updatePost] = useAtom(updatePostAtom);
  const [title, setTitle] = useAtom(titleAtom);
  const [content, setContent] = useAtom(contentAtom);
  const [isSelectedPostAtom] = useAtom(selectedPostAtom);

  const handleSubmit: React.FormEventHandler = (event) => {
    event.preventDefault();
    isSelectedPostAtom === null ? createPost() : updatePost();
    onClose();
  };

  return (
    <>
      <Drawer isOpen={isOpen} onClose={onClose} placement="bottom">
        <DrawerOverlay />
        <DrawerContent>
          <Container maxW="container.md" padding="20">
            <form onSubmit={handleSubmit}>
              <DrawerHeader>Title</DrawerHeader>
              <DrawerCloseButton />
              <DrawerBody>
                <FormControl>
                  <FormLabel>Post Title</FormLabel>
                  <Input
                    type="text"
                    placeholder="post title..."
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                  />
                </FormControl>
                <FormControl>
                  <FormLabel>Post Content</FormLabel>
                  <Textarea
                    placeholder="post content..."
                    value={content}
                    onChange={(e) => setContent(e.target.value)}
                  />
                </FormControl>
              </DrawerBody>
              <DrawerFooter justifyContent={'space-between'}>
                <Button onClick={onClose}>Cancel</Button>
                <Button
                  disabled={
                    isSelectedPostAtom === null ? !enableCreate : !enableUpdate
                  }
                  type="submit"
                >
                  Save
                </Button>
              </DrawerFooter>
            </form>
          </Container>
        </DrawerContent>
      </Drawer>
    </>
  );
};

export default PostEditor;

setSelectedPostの有無でcreatePostにするのか、updatePostにするのかを切り替えています。

反省点

今回、スタイリングが面倒くさいからとChakra UIで横着したのですが、バンドルサイズがとんでもないことになりました。

Route (pages)                              Size     First Load JS
┌ ○ / (1163 ms)                            93.9 kB         248 kB
├   /_app                                  0 B             154 kB

Chakra UIもそうですが、firestoreもそれなりも大きいです。

https://bundlephobia.com/package/@firebase/firestore@3.8.1

元々、ReduxのクソデカProviderが嫌でjotaiを始めたのですが、Chakra UIもやめる必要が出てきたようです。TailwindCSSかvanilla-extractか、そのうち試していきたいです。

Discussion