Firestoreとjotaiでリアルタイムにデータを同期する
この記事では簡単なPostアプリのCRUD操作をjotaiとFirebaseのCloud Firestoreを使って実現しています。
FirestoreのonSnapshot
とjotaiを使って、クライアント間でデータをリアルタイムに同期するプログラムを書きました。
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の定義をしています。
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
コンポーネントのコードです。
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)
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>
</>
)
こちらの記事を参考にして書きました
postの追加(AddPost.tsx)
以下はpostを追加するためのフローティングボタンです。
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を表示します。新規追加なので、selectedPost
をnull
に設定します。
postの編集Drawer(PostEditor.tsx)
postを編集するためのDrawerです。
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もそれなりも大きいです。
元々、ReduxのクソデカProviderが嫌でjotaiを始めたのですが、Chakra UIもやめる必要が出てきたようです。TailwindCSSかvanilla-extractか、そのうち試していきたいです。
Discussion