- 投稿日:2022-02-28T22:58:19+09:00
「Immer」で簡単!イミュータブル!
はじめに 株式会社Another worksでインターンをしている、nonoyamalfoyと申します。 普段はReactNativeでモバイルアプリの開発をしています。 先日LTで登壇した内容を記事にしました。気軽に見ていただけると幸いです。 背景 以前、オブジェクトの参照とコピーについて記事を投稿しました。 簡単に説明すると スプレッド構文や、Object.assignはシャローコピーなので、深いネストを持つオブジェクトのコピーを行う際は注意する必要がある。といった内容でした。 reactではイミュータブルを意識して実装する必要があり、stateの更新前にオブジェクトのコピーを行うことが多々あるかと思います。 しかし、深いネストを持つオブジェクトをイミュータブルを上記のコピーではネストを気にしないと行けないので面倒ですし、ミスが発生するかもしれません。 そこで今回は、イミュータブルを意識した実装を容易にしてくれる「immer」についてご紹介したいと思います。 イミュータブルについて イミュータブルとは「不変性」を意味します。つまり、値が変更されないことを指します。 イミュータブルを意識することで意図しない値の変更を防ぐことができます。 どこかで意図せず変更があるかもしれないと恐怖ですよね。 また、reactの公式ドキュメントでは以下のように記されています。 ミュータブル (mutable) なオブジェクトは中身が直接書き換えられるため、変更があったかどうかの検出が困難です。ミュータブルなオブジェクト変更の検出のためには、以前のコピーと比較してオブジェクトツリーの全体を走査する必要があります。 イミュータブルなオブジェクトでの変更の検出はとても簡単です。参照しているイミュータブルなオブジェクトが前と別のものであれば、変更があったということです。 このように、Reactで実装していく上ではイミュータブルを意識することは重要になってきます。 immerについて immerの公式ドキュメントには以下のように記されています。 内容からもReactでの実装を意識して作成されていることがわかります。 Immerは、不変の状態をより便利な方法で操作できる小さなパッケージです。Reactステート、ReactやReduxのリデューサー、構成管理などと組み合わせて使うことができます。イミュータブルなデータ構造は、(効率的な)変更検出を可能にします。オブジェクトへの参照が変更されていなければ、オブジェクト自体も変更されていないことになります。さらに、クローンを比較的安価に作成することができる。データツリーの変更されていない部分はコピーする必要がなく、同じ状態の古いバージョンとメモリ上で共有される。 後ほど触れますが、最後の部分が特徴的ですね。 クローンを比較的安価に作成することができる。データツリーの変更されていない部分はコピーする必要がなく、同じ状態の古いバージョンとメモリ上で共有される。 実はReactの公式ドキュメントでも深くネストされたオブジェクトでイミュータブルを守る際の選択肢として推奨されています。 実際に使ってみる 復習も兼ねてまずはスプレッド構文の例を見ていきます。 深くネストされたオブジェクトをスプレッド構文でコピーし、値を変更します。 const obj1: Obj = { p1: "p1", nest1: { p2: "p2", nest2: { p3: "p3" } } } // この場合、shallowcopyになりコピー元が変更されてしまう const copyObj1 = { ...obj1 } copyObj1.nest1.nest2.p3 = "spread test!" console.log(obj1.nest1.nest2.p3) // spread test! // 元の値も変更されてしまう。 console.log(copyObj1.nest1.nest2.p3) // spread test! 実際はスプレッド構文でもネストされている部分までコピーすればイミュータブルを実現することができますが、ネストが何重にもなるとかなり面倒です。 続いてimmerの例を見ていきます。 immerではproduceという関数が提供されており、お手軽にイミュータブルを実現できます。 produceは第1引数に変更したい値を受け取り、第2引数に状態を変更する関数を受け取ります。 そして変更された値が返却されます。変更の際に値がコピーされているので、元の値に影響はありません。 import produce from "immer"; const obj1: Obj = { p1: "p1", nest1: { p2: "p2", nest2: { p3: "p3" } } } const copyObj2 = produce(obj1, draft => { draft.nest1.nest2.p3 = "immer test!" }) console.log(obj1.nest1.nest2.p3) // p3(元の値は変更されていない) console.log(copyObj2.nest1.nest2.p3) // immer test! また、produceの第一引数に関数を渡すことで汎用的な関数を作成できます。 いわゆるカリー化というものです。何度も変更がある場合に便利ですね! const changeP3 = produce((draft: Obj, value)=> { draft.nest1.nest2.p3 = value }) const copyObj2 = changeP3(obj1, "immer test!") lodash cloneDeepとの比較 今までネストが深いオブジェクトをコピーする際はcloneDeepで対応してしまっていたので、どのような違いがあるのか比較してみました。 挙動の違い cloneDeepはすべてのオブジェクトツリーをコピーする一方で、冒頭で述べたようにimmerは変更される部分のみコピー(メモリを確保)してくれます。 先程のオブジェクトにnest3を加えて検証して行きます。 import produce from "immer"; import {cloneDeep} from "lodash" const obj1: Obj = { p1: "p1", nest1: { p2: "p2", nest2: { p3: "p3" }, nest3: { p4: "p4" } } } // immer const changeP3 = produce((draft, value) => { draft.nest1.nest2.p3 = value }) const copyObj2 = changeP3(obj1, "immer test!") // cloneDeep const copyObj3 = cloneDeep(obj1) copyObj3.nest1.nest2.p3 = "cloneDeep test!" // 結果 console.log(copyObj2.nest1.nest2.p3) // → immer test! console.log(copyObj3.nest1.nest2.p3) // → cloneDeep test! // メモリ空間が同じかどうか比較 console.log(obj1.nest1.nest3 === copyObj2.nest1.nest3) // → true // immerは必要のないコピーを行わないので、p3とオブジェクトツリー上で関係のないnest3はコピーされない。 console.log(obj1.nest1.nest3 === copyObj3.nest1.nest3) // → false // cloneDeepを使用すると、不要なコピーが生じてしまう。 パフォーマンス 簡易的にベンチマークを測定。 要素数が少なければ大差はないが、10000件程度の配列になると倍くらい変わってくる。 const obj1: Obj = { p1: "p1", nest1: { p2: "p2", nest2: { p3: "p3" }, nest3: { p4: "p4" } } } const arr1: Obj[] = [] for (let i = 0; i < 10000; i++) { arr1.push(obj1) } // immer test const immerStartTime = performance.now(); const changeP3 = produce((draft: Obj[], value: string) => { draft[0].nest1.nest2.p3 = value }) const copyObj2 = changeP3(arr1, "immer test!") const immerEndTime = performance.now(); // cloneDeep test const cloneDeepStartTime = performance.now(); const copyArr2= cloneDeep(arr1) copyArr2[0].nest1.nest2.p3 = "cloneDeep test!" const cloneDeepEndTime = performance.now(); console.log(immerEndTime - immerStartTime); // → 4.699999988079071(ms) console.log(cloneDeepEndTime - cloneDeepStartTime); // → 9.5(ms) パッケージのサイズ比較 immer lodash bundle size 5.6kb 24.5kb download time 6ms 28ms イミュータブルのための導入であればimmerがよいかと! use-immer 同時に提供されているuseImmerというライブラリを利用して、stateをを更新する際に簡単にイミュータブルを実現できます。 produceでも実現できますが、より簡略化できます。 使い方としてはuseStateと非常に似ており、set関数で値をすると、内部でコピーを行ってくれます。 import { useImmer } from 'use-immer'; // ... const [obj, setObj] = useImmer<Obj>({ p1: "p1", nest1: { p2: "p2", nest2: { p3: "p3" }, } }); const changeP3 = (value: string) => { // useStateを使用している場合はここにコピーの処理を書く必要がある。 setObj((draft) => { draft.nest1.nest2.p3 = value }) } useReducer用のuseImmerReducerも提供されています。 reduxでの活用 produceの第一引数に渡し、switch文で変更したいstateを変更するだけです。 reduxあるあるの多量のスプレッド構文が消え、スタイリッシュに書くことができます。 import produce from "immer" const initialState = { todos: [] } const todosReducer = produce((draft = initialState, action) => { switch (action.type) { case "toggle": const todo = draft.todos.find(todo => todo.id === action.payload) todo.done = !todo.done break case "add": draft.todos.push({ id: action.payload, title: "new todo", done: false }) break default: break } }) おまけ reduxについては正規化が推奨されており、なるべくネストは避けるべきとのこと。(優先度B) ネストの多いオブジェクトのイミュータブルを意識することも大切だが、ネストを減らした実装を意識することも必要かも。 多くのアプリケーションは、複雑なデータをストアにキャッシュする必要があります。そのデータはしばしばAPIからネストされた形で受け取られたり、データ内の異なるエンティティ間の関係を持っています(例えば、ユーザー、投稿、コメントを含むブログなど)。 そのようなデータは、ストアに「正規化」された形で保存することをお勧めします。これにより、IDに基づいたアイテムの検索や、ストア内の単一のアイテムの更新が容易になり、最終的にはパフォーマンス・パターンの改善につながります。 最後に immerを使用することで、イミュータブルな実装が容易になりましたね。 イミュータブルを実現するためのライブラリとして、「immutable.js」、「Ramda」、「immutability-helper」などが存在します。 今回は、React公式が推奨している「immer」をピックアップさせていただきましたが、機会があればそれらのライブラリについても調査してみたいと思います。 ▼複業でスキルを活かしてみませんか?複業クラウドの登録はこちら! https://talent.aw-anotherworks.com/ 参考
- 投稿日:2022-02-28T11:01:02+09:00
export * fromとは何なのか
はじめに ある時こんなファイルを見て「???」となったので調べてみた。 index.ts export * from '...略' export * from '...略' 結論 様々なモジュールから様々なエクスポートを集約した1つのモジュールを作成しているということらしい。 つまり集約元として動作するのみのモジュールということ。 例えば以下のようなコンポーネントがあるとする src/components/Layout/Header.tsx export const Header = () => { return <header>ヘッダー</header> } src/components/Layout/Footer.tsx export const Footer = () => { return <footer>フッター</footer> } 上記のコンポーネントを集約したモジュールを作成する src/components/Layout/index.ts export * from './Header' export * from './Footer' コンポーネントを読み込むときは集約したモジュールから読み込むだけ importの記述がスッキリする! src/pages/index.tsx // スッキリ爽快 import { Header, Footer } from '@/components/Layout' export const Top = () => { return ( <> <Header /> <h1>TOP</h1> <Footer /> </> ) } 以上!! 何がうれしいのか モジュールを集約しなかった時の場合importの記述が煩雑になる コンポーネントが増えてくるとより恩恵を受けられそう。 src/pages/index.tsx // モジュールを集約しなかったときの場合 import { Header } from '@/components/Layout/Header' import { Footer } from '@/components/Layout/Footer' export const Top = () => { return ( <> <Header /> <h1>TOP</h1> <Footer /> </> ) } 注意点 export * from ...は名前付きエクスポートのみ有効になる 以下はバッドパターン src/components/Layout/Header.tsx const Header = () => { return <header>ヘッダー</header> } export default Header index.ts export * from './Header' // エラー。再エクスポートできない。 参考資料
- 投稿日:2022-02-28T08:54:08+09:00
React Hook FormとChakraUIを組み合わせて使ってみよう
はじめに 現在、駆け出しエンジニア同士でチームを結成しアプリ開発をしています。 アプリ開発の過程でChakraUIとReact Hook Formの組み合わせ方法について学んだので、共有します。 React Hook Formの導入にきっかけ Reactのフォームバリデーションライブラリとして有名なReact Hook Formですが、当初アプリ開発をスタートした際、この存在を知りませんでした。 そのため、各フォームの項目をuseStateで用意して独自にバリデーションを用意して進めていました。 中間レビューで現役エンジニアにレビューをもらった際に、これではパフォーマンスが悪いので、React Hook Formを導入したほうがいいと指摘があり、そこからReact Hook Form導入の検討を開始しました。 React Hook Form導入前は以下のような形で、これらが各入力に関わるコンポーネントに存在している状況でした。 また新規登録と編集で似たようなページがあるのですが、これもそれぞれコンポーネントを作り、実装してしまっており、共通化されていない状況でした。 イケてない。。。 export const PartnerInput = () => { const [name, setName] = useState(""); const [zipcode, setZipcode] = useState(""); const [prefecture, setPrefecture] = useState(""); const [address, setAddress] = useState(""); const [phone, setPhone] = useState(""); const [memo, setMemo] = useState(""); React Hook Formを導入する chakraUIとReact Hook Formを組み合わせて導入する方法は以下に記載があり、単一コンポーネントであれば、比較的簡単に導入できます。 chakraUIをインストール yarn add @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6 React Hook Formをインストール yarn install react-hook-form 単一のコンポーネントで組み合わせて使う場合 フォームに必要なInputコンポーネントとSubmitするコンポーネントが同じ場合、 以下のchakraUIのリファレンスを参考に、進めると完成します。 import { useForm } from 'react-hook-form' import { FormErrorMessage, FormLabel, FormControl, Input, Button, } from '@chakra-ui/react' export const PartnerInput = () => { const { handleSubmit, register, formState: { errors, isSubmitting }, } = useForm() const onSubmit = data => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <FormControl isInvalid={errors.name}> <FormLabel htmlFor='name'>First name</FormLabel> <Input id='name' placeholder='name' {...register('name', { required: 'This is required', minLength: { value: 4, message: 'Minimum length should be 4' }, })} /> <FormErrorMessage> {errors.name && errors.name.message} </FormErrorMessage> </FormControl> <Button mt={4} colorScheme='teal' isLoading={isSubmitting} type='submit'> Submit </Button> </form> ) } 2つのコンポーネントの組み合わせで使う場合 一方で、新規登録と編集ページが同じである場合やデータA(例:注文)とデータB(例:顧客)の入力画面が同じである場合、コンポーネントを分けて、Inputコンポーネントを共通化したくなってくるかと思います。 その場合は少し記載方法が異なり、以下のReact Hook FormのuseFormContextに記載内容を参考に、進めると完成します。 import { useForm, FormProvider } from "react-hook-form"; export const Partner = () => { const methods = useForm(); const onSubmit = data => console.log(data); return ( <FormProvider {...methods} > <form onSubmit={methods.handleSubmit(onSubmit)}> <PartnerInput /> </form> </FormProvider> ); } import { useFormContext } from "react-hook-form"; import { FormErrorMessage, FormLabel, FormControl, Input, Button } from "@chakra-ui/react"; export const PartnerInput = () => { const { register, formState: { errors, isSubmitting }, } = useFormContext(); return ( <> <FormControl isInvalid={errors.name}> <FormLabel htmlFor="name">First name</FormLabel> <Input id="name" placeholder="name" {...register("name", { required: "This is required", minLength: { value: 4, message: "Minimum length should be 4" }, })} /> <FormErrorMessage>{errors.name && errors.name.message}</FormErrorMessage> </FormControl> <Button mt={4} colorScheme="teal" isLoading={isSubmitting} type="submit"> Submit </Button> </> ); }; 3つのコンポーネントで組み合わせる場合 上記で進めると、親のコンポーネントと子のコンポーネントに分けることができるようになりますが、一方で、InputにまつわるFormControlやFormLabelなどをそれぞれのコンポーネントでChakraUIからimportしなくてはなりません。 ここでは、Text入力するためのInputコンポーネントを1つ作成し、顧客の新規登録、顧客の編集、注文の新規登録、注文の編集といった複数の利用シーンでコンポーネントを共通化できるようにします。 import { useFormContext } from "react-hook-form"; import { Button } from "@chakra-ui/react"; import { InputTextForm } from "../molecules/Input/Form/InputTextForm"; export const PartnerInput = () => { const { register, formState: { errors, isSubmitting }, } = useFormContext(); return ( <> <InputTextForm id="name" label="First name" placeholder="name" isInvalid={errors?.name} register={register("name", { required: "This is required", minLength: { value: 4, message: "Minimum length should be 4" }, })} /> <Button mt={4} colorScheme="teal" isLoading={isSubmitting} type="submit"> Submit </Button> </> ); }; import { FormLabel, FormControl, Input, Box, FormErrorMessage } from "@chakra-ui/react"; export const InputTextForm = (props) => { const { register, id, label, isInvalid, placeholder } = props; return ( <> <FormControl id={id} isInvalid={isInvalid}> <FormLabel htmlFor={id}>{label}</FormLabel> <Box> <Input {...register} type="text" placeholder={placeholder} /> <FormErrorMessage>{isInvalid && isInvalid.message}</FormErrorMessage> </Box> </FormControl> </> ); }; 最後に 上記のように共通化することで、コードはすっきりするのですが、一方で、顧客の新規登録・編集で少し違った表現をしなければならなかったり、顧客と注文で違ったりすると、pathでの分岐やpropsの受け渡しがやたら多くなり可読性が落ちるなとも思いました。 この辺はuseStateでの実装を思いっきりリファクタリングにして(しすぎてみて)、やってみて理解が深まったところだったので、非常によい体験学習でした。 アプリリリースまであともう少し、引き続き、頑張っていこうと思います。