Edited at

なぜReactHooksを使うとrecomposeが不要になるのか?


はじめに

React16.8からReactHooksが利用可能になり、

これまで複雑になりがちだった状態を持つロジック(stateful logic)の記述、共有がシンプルなコードでできるようになりました。

ReactHooks登場以前までよく使われていたrecomposeの中でも

ReactHooksを使えばrecomposeが解決したかった問題は全部解決するぜ!と言っています。


Today, we announced a proposal for Hooks. Hooks solves all the problems I attempted to address with Recompose three years ago, and more on top of that.


そこで今回は色々な場面における



  • :umbrella: ReactHooksなし


  • :cloud: recompose


  • :sunny: ReactHooksあり

(アイコンはだんだん良くなっている様を表したつもりです笑)

の3つの書き方を比較して、なぜReactHooksを使うとrecomposeが不要になるのかを見ていきたいと思います。

比較することでなぜReactHooksが必要だったかということがよく理解できるかと思います。

特にReactHooksについては今後使っていくものなので詳しめに説明を入れています。

また今回説明のために作成したコードはこちらのCodeSandboxから参照、編集できるので合わせてご覧ください。


対象読者


  • Reactそのものにはある程度慣れていて、HOCなどの概念は理解している方

  • ReactHooksの何が嬉しいのかをざっくり知りたい方

  • recomposeを使っているがReactHooksに移行したいと考えている方


状態を持つコンポーネントの定義


:umbrella: ReactHooksなし

状態を持つコンポーネントを定義したい場合、functional componentでは実現できず、classを使う必要がありました。いちいちconstructorなどを定義せねばならず、かなり冗長です。

class Example extends React.Component {

constructor(props) {
super(props);
this.state = {
count: 0
};
}

render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}


:cloud: recompose

recomposeのwithStateHandlerを使うことで、状態を持ったfunctional componentを定義することができます。

ただこれもclassでしかstateを定義できないというReactそのものの制約があったので、内部的にはclassを返すHOCを定義することでこれを実現しています。

そのためpropsに色々情報を渡さなければならず、ぱっと見わかりやすいかいうと微妙です。


const WithRecompose = withStateHandlers(
{
count: 0
},
{
increment: ({ count }) => () => ({ count: count + 1 })
}
)(({ count, increment }) => (
<div>
<p>You clicked {count} times</p>
<button onClick={() => increment()}>Click me</button>
</div>
));


:sunny: ReactHooksあり

ReactHooksのuseStateを使うことでHOCなど使わずにコンポーネント内にstateを定義できるようになりました。

const WithHooks = () => {

const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
};

以下のような形で定義、更新することができます。

// const [stateの変数名, stateを更新するメソッド] = useState(stateの初期値)

const [count, setCount] = useState(0);
...
// stateを更新する
<button onClick={() => setCount(count + 1)}>Click me</button>

従来のstateと異なる点として


  • stateを更新するメソッドは引数の値がそのまま値として上書き設定される。setStateと違い状態をマージしない。

  • stateの初期値は今までのstateと違いオブジェクトである必要がない。(もちろんオブジェクトにすることもできる)

が挙げられます。

また配列などで現在の値を直接更新するとうまく動かなかったので、reduxのreducer同様新しいstateを生成してセットする必要があるようです。

const [tasks, setTasks] = useState([]);

...
// tasks.push('new task') x
const tasks = [...tasks, 'new task']; // ○
setTasks(tasks)


ライフサイクルイベント


:umbrella: ReactHooksなし

componentDidMount, componentDidUpdate, componentUnmountといったライフサイクルイベントを扱うためには、状態を持つコンポーネントのときと同様にclassを用いる必要がありました。

class WithoutHooks extends React.Component {

constructor(props) {
super(props);
}

componentDidMount() {
console.log("WithoutHooks: didMount");
}

componentDidUpdate() {
console.log("WithoutHooks: didUpdate");
}

componentWillUnmount() {
console.log("WithoutHooks: unMount");
}

render() {
return (
<div>
<p>Nothing To Render </p>
</div>
);
}
}


:cloud: recompose

これをrecomposeではlifecycleというHOCを使うことでfunctional componentでも利用可能にしています。

ただこれもわかりやすくなったかというとあんまり変わらない様に思いますね。

const WithRecompose = lifecycle({

componentDidMount() {
console.log("WithRecompose: didMount");
},
componentDidUpdate() {
console.log("WithRecompose: didUpdate");
},
componentWillUnmount() {
console.log("WithRecompose: unMount");
}
})(() => {
return (
<div>
<p>Nothing To Render </p>
</div>
);
});


:sunny: ReactHooksあり

ReactHooksではEffect Hookを使うことによってfunctional componentでもコンポーネント内にライフサイクルフックを定義することができるようになります。

const WithHooks = () => {

useEffect(() => {
console.log("WithHooks: update");
return function cleanup() {
console.log("WithHooks: unmount");
};
});
return (
<div>
<p>Nothing To Render </p>
</div>
);
};

以下のように書くことでコンポーネントがrenderされたときに毎回走る処理、unMount時に走らせたい処理(optinal)を書くことができます。

useEffect(() => {

// render毎に行いたい処理
return () => {
// unMount時に行いたい処理(optional)
}
})

今までの書き方と大きく変わっていますが、公式ドキュメントにはcomponentDidMount, componentDidUpdate, and componentWillUnmountを1つにまとめたようなイメージと考えれば良いよーと書いています。


If you’re familiar with React class lifecycle methods, you can think of useEffect Hook as componentDidMount, componentDidUpdate, and componentWillUnmount combined.


DidMountとDidUpdateを1つにまとまってしまったのがなんで?と気になったのですが、DidMountで初期化処理をした値が更新されたまま残ってしまうバグを避けるためだとのことです

また、useEffectは複数定義できるので以下のように

関連するパラメータごとにuseState, useEffectを定義してコードの見通しを良くすることができます。

function FriendStatusWithCounter(props) {

// countのuseState, useEffect
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});

// isOnlineのuseState, useEffect
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
// ...
}

いやいやそんな毎回useEffectが走ったらパフォーマンスがめっちゃ落ちる、特定の値が更新されたときだけ走らせたい!という場合は以下のようにuseEffectの第2引数に変更を検知したいstateの値やpropsを書いておくことで、その値が変更された場合のみuseEffectを走らせる事ができます。


const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // countが更新された場合のみ

useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // friend.id propsが更新された場合のみ

useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, []); // mount時のみcomponentDidMountと同義


状態ロジックの共有


:umbrella: ReactHooksなし

例えば


  • countという状態変数

  • CountUpというcountを+1するメソッド

を持つ状態ロジックを色々なコンポーネントで共有したい場合、これまではHOC(かrender props)を使う必要がありました。


const WithoutHooks = ComposedComponent => {
return class HOC extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
this.countUp = this.countUp.bind(this);
}

countUp() {
this.setState({ count: this.state.count + 1 });
}

render() {
return (
<ComposedComponent count={this.state.count} countUp={this.countUp} />
);
}
};
};

const WithoutHooksComponent = WithoutHooks(({ count, countUp }) => (
<div>
<div>{count}</div>
<button onClick={countUp}>countUp</button>
</div>
));


:cloud: recompose

recomposeを使っても、状況は大きく変わらず、withStateHandlerを使ったHOCを定義することで実現できます。

const WithRecompose = ComposedComponent => {

withStateHandlers(
{
count: 0
},
{
countUp: ({ count }) => () => ({ count: count + 1 })
}
)(({ count, countUp }) => (
<ComposedComponent count={this.state.count} countUp={this.countUp} />
));
};

const WithRecomposeComponent = WithoutRecompose(({ count, countUp }) => (
<div>
<div>{count}</div>
<button onClick={countUp}>countUp</button>
</div>
));


:sunny: ReactHooks

ReactHooksではCustomHookを定義することでこの問題を解決します。


const useCount = () => {
const [count, setCount] = useState(0);
return [count, () => setCount(count + 1)];
};

const WithHooksComponent = () => {
const [count, countUp] = useCount();
return (
<div>
<div>{count}</div>
<button onClick={countUp}>countUp</button>
</div>
);
};

CustomHookと聞くと難しそうに聞こえますが今回の場合はuseCountという関数を定義して(CustomHookを定義する場合はuse~を使うのが推奨)、その中でuseStateを使い、countUpする関数を返しています。

const useCount = () => {

const [count, setCount] = useState(0);
return [count, () => setCount(count + 1)];
};

またここが一番のミソですがCustomHookを利用するコンポーネントは、CustomHookをコンポーネント内で呼び出すだけでOKでHOCのような複雑な記述をする必要がなく、容易に再利用することができます。

const WithHooksComponent = () => {

const [count, countUp] = useCount(); // CustomHookを呼び出し
return (
<div>
<div>{count}</div>
<button onClick={countUp}>countUp</button>
</div>
);
};

苦労して理解したHOCでしたがReactHooksを使ったほうが断然コードの可読性が高くなりますね。

recomposeの作者が今後は開発しないよと言ったのも納得できますね :thumbsup:

参考:

Introducing Hooks

recompose