よいDXに向けてRecomposeのHOCをReact Hooksに置き換える

English version is here.

どうもTAKUYAです。InkdropというMarkdownノートアプリを1人で作っています。そのモバイル版はReact Nativeで組まれています。最近コードベースをリファクタリングして、RecomposeからReact Hooksに乗り換えました。本稿ではその作業の際に発見したコツなどをシェアしたいと思います。

HOC多用はメンテナンス性が低くなる

RecomposeとはHOC(Higher-order components)で効率よくStateless functional componentsベースのReactアプリを組むための便利関数ライブラリです。作者はAndrew Clarkです。例えるなら、React版Lodashみたいな感じです。このライブラリの開発は2018年12月4日を最後に止まっています。なぜなら彼がReactのチームに参加したからです。そしてReact HooksがReact v16.8にて導入されました。

彼らが言うように、React Hooksを使うためにわざわざ夜を徹して既存コードを書き直す必要はありません。でも僕はやりました。自分のコンポーネント群を書き直した理由はいくつかあります。RecomposeはHOCベースでアプリを書くためのライブラリです。HOCの関数群は再利用性が高いため、同じロジックを書く手間を大きく省いてくれます。一方で、このRecomposeとHOCを採用することによるデメリットもあることが分かりました:

1. スタティックタイピングが上手く動かない

僕はプロジェクトのタイプチェックにFlowを使っています。RecomposeのFlow定義は次の例のように、FlowのType Inference機能に強く依存しています:

import { compose, withHandlers, pure, type HOC } from 'recompose'
type Props = {}
const enhance: HOC<*, Props> = compose(
connect(({ editingNote, editor, session }) => ({
editingNote,
readOnly: editor.readOnly || session.isReadOnly
})),
pure,
withKeyboard(),
withHandlers({
handleTitleFocus: _props => () => {}
})
)
const Editor = enhance(props => {
// ...
})

これだとなぜか props が頻繁に any として解決されてしまって、コンポーネント中のタイプチェックが上手く働かない事があって困っていました。Flowは僕が間違ったプロパティを参照しようとしていても教えてくれません。これでは意味がない。さらに、 props の中身がコードを見ただけではすぐに分からないという問題もあります。HOCの入れ子が増えれば増えるほど、この問題は深刻化します。

2. React Developer Toolsでデバッグしづらい

Many nested HOCs appear in Elements

上記画像をご覧の通り、たくさんのpure(withHandlers(Component)) のようなHOCが積み重なっており、実際のコンポーネント構造が全く把握できません。そのせいでReact Developer Toolsの実用性がほとんど失われてしまっています。

3. fbjsに依存している

Recomposeはfbjsに依存しています — — これはFacebookが内部で使っている便利ライブラリですが、今は廃止されてメンテされていません。このライブラリが更に古いモジュールに依存しているため、使うだけで不必要にプロジェクトは肥大化し脆弱になってしまします。なので、僕はいつもfbjsが依存ツリーに含まれていないか確認しては除去するように努めています。

既にデスクトップ版にてReact Hooksを採用しており、それが上記で議論した問題を上手く解決してくれることを実感していました。Hooksによってより快適なDX(Development Experience)が得られることが分かりました。Hooksはコードベースを見通しよくしてくれます。グッジョブ、Andrew Clark。

では、以降より実際にどのようにRecomposeをReact Hooksに置き換えたのかご紹介します。実際は想像するよりとても簡単でした。これは新しいプロジェクトでHooksの使い方を学ぶ際にも参考になるかと思います。

よりよいDXに向けてコードを綺麗にするぞ

以下はRecomposeの各ユースケースに対するコードのビフォーアフターです。

Recompose.lifecycle -> React.useEffect

Before:

const PostsList = ({ posts }) => (
<ul>{posts.map(p => <li>{p.title}</li>)}</ul>
)
const PostsListWithData = lifecycle({
componentDidMount() {
fetchPosts().then(posts => {
this.setState({ posts });
})
}
})(PostsList);

After:

import { useEffect, useState } from 'react'const PostsList = () => {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetchPosts().then(posts => {
setPosts(posts);
})
}, [])
return (
<ul>{posts.map(p => <li>{p.title}</li>)}</ul>
)
}

Recompose.withHandlers -> useCallback

Before:

const enhance = compose(
withState('value', 'updateValue', ''),
withHandlers({
onChange: props => event => {
props.updateValue(event.target.value)
},
onSubmit: props => event => {
event.preventDefault()
submitForm(props.value)
}
})
)
const Form = enhance(({ value, onChange, onSubmit }) =>
<form onSubmit={onSubmit}>
<label>Value
<input type="text" value={value} onChange={onChange} />
</label>
</form>
)

After:

import { useState, useCallback } from 'react'const Form = () => {
const [value, updateValue] = useState('')
const onChange = useCallback(event => {
updateValue(event.target.value)
}, [updateValue])
const onSubmit = useCallback(event => {
event.preventDefault()
submitForm(value)
}, [value])
return (
<form onSubmit={onSubmit}>
<label>Value
<input type="text" value={value} onChange={onChange} />
</label>
</form>
)
}

Recompose.withStateHandlers -> React.useState

Before:

const Counter = withStateHandlers(
({ initialCounter = 0 }) => ({
counter: initialCounter,
}),
{
incrementOn: ({ counter }) => (value) => ({
counter: counter + value,
}),
decrementOn: ({ counter }) => (value) => ({
counter: counter - value,
}),
resetCounter: (_, { initialCounter = 0 }) => () => ({
counter: initialCounter,
}),
}
)(
({ counter, incrementOn, decrementOn, resetCounter }) =>
<div>
<Button onClick={() => incrementOn(2)}>Inc</Button>
<Button onClick={() => decrementOn(3)}>Dec</Button>
<Button onClick={resetCounter}>Reset</Button>
</div>
)

After:

import { useState, useCallback } from 'react'const Counter = () => {
const [counter, setCounter] = useState(0)
const incrementOn = useCallback((value) => {
setCounter(counter + value)
}, [counter])
const decrementOn = useCallback((value) => {
setCounter(counter - value)
}, [counter])
const resetCounter = useCallback(() => {
setCounter(0)
}, [counter])
return (
<div>
<Button onClick={useCallback(() => incrementOn(2), [counter])}>Inc</Button>
<Button onClick={useCallback(() => decrementOn(3), [counter])}>Dec</Button>
<Button onClick={resetCounter}>Reset</Button>
</div>
)
}

Recompose.pure -> React.memo

Before:

const Comp = pure(props => <div>{props.message}</div>)

After:

const Comp = memo(props => <div>{props.message}</div>)

Recompose.onlyUpdateForKeys -> React.memo

Before:

const enhance = onlyUpdateForKeys(['title', 'content', 'author'])
const Post = enhance(({ title, content, author }) =>
<article>
<h1>{title}</h1>
<h2>By {author.name}</h2>
<div>{content}</div>
</article>
)

After:

import { memo } from 'react'const Post = memo(({ title, content, author }) => {
return (
<article>
<h1>{title}</h1>
<h2>By {author.name}</h2>
<div>{content}</div>
</article>
)
}, (prevProps, nextProps) => {
return prevProps.title === nextProps.title &&
prevProps.content === nextProps.content &&
prevProps.author === nextProps.author
})

From the doc: You can also add a second argument to specify a custom comparison function that takes the old and new props. If it returns true, the update is skipped.
訳: 新旧のpropsを比べる関数を二つ目の引数で指定できます。もしtrueを返せば、コンポーネントの更新はスキップされます。

お気づきかもしれませんが、React Hooksに置き換えたからといってコード量がごそっと減ることはありません。なぜならRecomposeで既に十分効率的に書けるからです。さて、このリファクタリングによってReactコンポーネントの構造がDeveloper Tools上でかなり見通し良くなりました:

このリファクタリング作業によって、Flowが正しく動作するようになり、いくつかの不要なコードを除去することも出来ました。

本稿がRecomposeベースのプロジェクトをお持ちの方の参考になることを祈っています :)

--

--

Published in 週休7日で働きたい

フリーランスしつつ自作サービス開発しながら食っていきたいブログ

Written by Takuya Matsuyama

I’m an indie SaaS developer currently building a Markdown note-taking app called Inkdrop. https://www.inkdrop.app/ Homepage: https://www.craftz.dog/

No responses yet