Gyazzみたいなエディタ作りでReact.jsを学ぶ:hooksを使った書き方
クラスベースのコンポーネントはもう古い?
hooksで書くより回りくどいと感じた

(クラスベース未経験の発言なので留意)
そういう実験ができる規模で始めてるのでいろいろ意見ください、書き直して比較してみます

了解です!

ざっくりとですが、たとえば
Editor.js
ならこう書けそうです

Editor-hook.js// importこれであってるか自信ない
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import Line from './Line'
const useLines = (initialLines) => {
const [lines, setLines] = useState(initialLines);
useEffect(() => setLines(initialLines), [initialLines]);
これなんだろう?(無くても動いた)

useLines
に渡されるlines(
initialLines
)そのものの変更に対応するために入れました

<Editor lines={lines} />
のlinesを変更するロジックを入れると変化がわかるはず
あー、なるほど・・

まだデータのロードとかは頭が回ってないですね。きっとそこまで作ったらわかるのでしょう。
useEffectでstate更新について
これ、Lineコンポーネントに適切にkeyを指定すると要らなくなりますね

たぶん
アプリケーションのコード全体見てないので、どこで状態管理してるのかとか分からんので一概には言えないですが
Editor-hook.js const modify = useCallback(
(lineIdx, text) => setLines((prev) => {
prev[lineIdx] = text;
return [...prev];
}),
[]
);
return [lines, modify];
};
export const Editor = (props) => {
const [cursor, setCursor] = useState({
row: 0,
col: 0,
});
const [lines, modify] = useLines(props.lines);
const lineProps = useMemo(
() => lines.map((line, index) => ({
value: line,
handleChange: (e) => modify(index, e.target.value),
onKeyDown: (e) => setCursor((prev) => {
switch(e.key) {
case "ArrowUp":
return { row: prev.row - 1, col: prev.col };
case "ArrowDown":
return { row: prev.row + 1, col: prev.col };
default:
// 同じobjectを返せば再レンダリングされない
return prev;
}
}),
})),
[lines, modify]
);
return (<div>
{lineProps.map((props, index) => (
<Line
key={index}
isFocus={index === cursor.row}
{...props}
/>
))}
</div>);
};
逆にめんどくさくなっているような……
useLines()
に一部切り出したけど、中途半端かも
この規模だとあんまりうれしくない
event listenerまでまとめて配列にして生成したのは、再レンダリングを防ぐため
arrow functionの形で渡すと、毎回新しいobjectを生成することになるため、何度もevent listenerの登録解除が繰り返されてしまう
小規模のコードなら、あまりきにしなくてもいいっちゃいい
とてもありがたい!自分ではこれまだ書けないですね・・

そして逆に面倒くさくなっている気持ちは確かに・・・
これ自分が設計ミスっていると思うんですよね。ただより適切な設計がどういうものか自分にもわからないです……

ScrapBubbleの複雑さと同じ匂いを感じる
クラスコンポーネントでは、設計に悩む感じがなかったので、その点だけ見るとクラスベースの方が良さそうだな・・

それゆえに?見やすいというのも大事
元の設計ではevent listenerの差し替えによる再レンダリングとか考えてなかったけどどう動くんだろう?
動作内容自体は同じのはずです

ざっと書くつもりが、だいぶ大事になってしまった
行編集と文字編集とがばらばらになっているのがめんどく感じた
前者はReactで管理しているが、後者は<textarea />が管理している
とはいえ、文字編集までReactで管理させると、Scrapboxのように全てを自前で実装しなければならなくなるので、かなりハードルが高くなる
そうなんですよ、日本語入力とか考えるとこのくらいの役割分担の方が結果楽になりそうだなと思っています

ひとまずこの実装は汚いけど一度書いているので、まず知見のあるこっちでやってみます。
<Line />
を例にあげたほうが適切でした。こっちはかなりすっきりします

Line-hook.jsimport React, { useRef, useEffect } from 'react';
export const Line = (props) => {
const ref = useRef();
useEffect(() =>{
if (props.isFocus) {
ref.current?.focus?.();
}
} ,[props.isFocus]);
return (
<textarea
ref={ref}
value={props.value}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
/>
);
};
お、まだ理解してないけどソースはすっきりした気がします

refでDOMにアクセスしている点は同じですね

focus()
の実行タイミングを変えています
Componentの再レンダリング時( componentWillUpdate
)とunmount時( componentWillUnmount
)から
props.isFocus
の変更時( useEffect(() => {...}, [props.isFocus])
)に変えた
useEffect()は、第二引数の配列に指定した変数の変更をトリガーに実行されるevent listenerのようなイメージ
わかりやすい説明!

処理が一カ所にまとまった点がポイント
タイミング、これで問題ないかな、、後で実際に試してみます

DOM構築後にfocusしてほしい
確認した。動いた。
まさにこの例だ。クラスベースの書き方とフックを使った書き方が併記されている
僕は
関数コンポーネントだけでKozanebaなどを作ってます。Reactを始めたタイミングでもう「クラスコンポーネントもあるけど関数コンポーネントに移行していくよ」という流れだったので〜

使い分けるものではなくて、統一した方が良いんですかね?(上のEditor.jsの例はクラスベースの方が良い気がしてる・・)

移行という表現だし今から書くならクラスベースじゃない方が良さそうだな・・
「使い分ける」のためには両方を知って「どちらが向いてるか」を判断できるようになる必要があるが、僕は面倒だったので「移行していくって言ってるんだったら関数コンポーネントだけでいいや」と学習をサボった

ドキュメントのサンプルがクラスコンポーネントの書き方でだけ書かれてるとかで少し困った
普段仕事でもReactで書いてますが、関数コンポーネントとhooksでしか書いてません

hooksの使い方を理解すれば、特に問題なく使えるかと思われます。
→hookで作り直してみることにする
今わかっているおかしなところ
lintがエラーを出している
presrc\components\Editor.js
Line 16:5: Do not mutate state directly. Use setState() react/no-direct-mutation-state
Line 24:6: Do not mutate state directly. Use setState() react/no-direct-mutation-state
Line 28:6: Do not mutate state directly. Use setState() react/no-direct-mutation-state
setStateするStateを作る時に既存のStateを上書きしているのがダメ
わかっていたけど、これでも動くっしょと思って取り敢えず実装している
全部コピーすればよいのかな?
こんな感じに、前のobjectを破棄して作り直せばいいと思う

immutable.jsthis.setState({
cursor: {
row: this.state.cursor.row + 1,
col: this.state.cursor.col,
}
lines: this.state.lines
})
なんか大がかりになってしまった
stateを cursor
と lines
とで別々に持たせればすっきりしそう
あ、もしかしてクラスベースだと、 this.state
にすべての状態を詰め込まないといけないのか?
linesは別のオブジェクトにしないといけない? e.g.
[...this.state.lines]

オブジェクトがいくつも入れ子になってる時の非破壊的更新に関しては、手で書いて複雑だなーと思い出したタイミングで
immerを使うようになりました

immer!みてみる

取り敢えずまだ不要そう
名前だけ聞いたことあったけど、
非破壊更新を便利にやるやつだったのか

immer自体はReactと関係ないようだ
React hooks版
ちょっとwrapした程度の簡単なコード
このくらいなら楽にPreact hooksとも組み合わせられそうだ
→ hookで書き直した結果この部分はすっきりした
テキストエリアにフォーカスを与えているところ
こうするしかないのかな?
たぶんそれしかないです。DOM操作はref経由でやるしかない

→ このまま(useEffectに逃がしたが、本質は同じ)
マウスでフォーカス当てた後、入力すると(前回当たってた方に)戻される

hookで書き直してみる
ひとまずuseMemoとかは無しで愚直にやってみる

Editor.jsimport React, { useState, useEffect } from 'react';
import Line from './Line';
export const Editor = (props) => {
const [cursor, setCursor] = useState({
row: 0,
col: 0,
});
const [lines, setLines] = useState(props.lines);
return (
<div>
{lines.map((line, index) => (
<Line
key={index}
isFocus={index === cursor.row}
value={line}
onChange={(e) => ((i) => {
setLines((prev) => {
prev[i] = e.target.value;
return [...prev];
})
})(index)}
onKeyDown={(e) => setCursor((prev) => {
switch(e.key) {
case "ArrowUp":
return { row: prev.row - 1, col: prev.col };
case "ArrowDown":
return { row: prev.row + 1, col: prev.col };
default:
// 同じobjectを返せば再レンダリングされない
return prev;
}
})}
/>
))}
</div>);
};
export default Editor
最適化を除くとクラスベースなものと比べて読みにくいということはないかな
メモ化とかを頑張ったので可読性が下がったという理解
単に理解が追い付いていないだけかも
linePropsもあえてJSXの中に展開して書く事でひとまず短いうちは読みやすい書き方にした
Line.jsは上で

さんが示してくれたままのもの
いくつかこれには無駄がある
Editor.js
匿名関数がレンダリングのたびに生成・設定される
> event listenerまでまとめて配列にして生成したのは、再レンダリングを防ぐため
> arrow functionの形で渡すと、毎回新しいobjectを生成することになるため、何度もevent listenerの登録解除が繰り返されてしまう
気にするのは、画面がちらついて困るようになってからでもいいかも

Line.js
特に無さそう?
ひとまず最適化の話は考えずエディタとしての機能を足していこう
その流れでまたReact的な側面で詰まるだろうからそこで学びを得たい