SolidJSに入門してみた

2022.06.15

こんちには。

データアナリティクス事業本部機械学習チームの中村です。

今回は機械学習ではなくてフロントエンド話で、SolidJSのチュートリアルやってみた記事を書いてみたいと思います。

ちょっと前にフロントエンドを少しやっていたのですが、久しぶりに余暇を使って何か触りたいなと思い、SolidJSというものが目に入ったのがきっかけです。

なお本記事は以下のチュートリアルをやってみて、ココは結構Reactと違うなとか、個人的にハマった部分などフォーカスしています。

詳しく知りたい方はぜひチュートリアルをやってみてください!

環境セットアップ

お手元で動かしたい場合は、以下でOKです。

$ npx degit solidjs/templates/ts sample-solidjs
$ cd sample-solidjs
$ npm i
$ npm run dev

バンドラがViteだからかもしれないですが、起動はちょ~~~~速いです。Reactでもおなじみのグルグルするやつが起動します。

カウンタサンプルを例に

useStateの代わりにcreateSignal

よくあるボタンを押下するとカウントが進むサンプルを見てみます。

SampleCounter.tsx

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <p>Count is: {count()}</p>
    </>
  );
};

App.tsxももちろん以下のように書き換えます。

App.tsx

import type { Component } from 'solid-js';
import { SampleCounter } from './SampleCounter';

const App: Component = () => {
  return (
    <SampleCounter />
  );
};

export default App;

画面はこんな感じです。

ほぼReactと同じような書き味ですが、以下が異なります。

  • サンプルカウント数を保持するために、createSignalを使う。
  • createSignalで得られるものは、getter関数とsetter関数であるため、値にはcount()とカッコつきでアクセスする。

コンポーネントのレンダリングは1回だけ

以下のようにconsole.logを仕込んでみます。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  console.log('rendered SampleCounter');

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <p>Count is: {count()}</p>
    </>
  );
};

これを実行してもconsole.logは1回しか実行されません。

つまり、コンポーネント内で以下のようにカウントを倍にする処理をいれても表示には反映されません。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  console.log('rendered SampleCounter');

  const doubleCount = count() * 2

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <p>Count is: {count()}, DoubleCount is: {doubleCount}</p>
    </>
  );
};

これを解決するためには、JSXの部分で直接count()を参照するよう変更したり、

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  console.log('rendered SampleCounter');

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <p>Count is: {count()}, DoubleCount is: {count() * 2}</p>
    </>
  );
};

もしくは、以下のように値を計算する別の関数を定義する方法があります。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  console.log('rendered SampleCounter');

  const doubleCount = () => count() * 2;

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <p>Count is: {count()}, DoubleCount is: {doubleCount()}</p>
    </>
  );
};

これは、次のpropsの分割代入でReactivityが失われることと関連します。

分割代入をすると変更が伝搬しない

カウント表示部分を別のCompnentにしてみます。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <SubComponent value={count()}/>
    </>
  );
};

type SubComponentProps = {
  value: number;
};

const SubComponent: Component<SubComponentProps> = ({value}) => {

  return (
    <p>Count is: {value}</p>
  );
};

この書き方では、ボタンを押しても値が更新されません。 理由は({value})の部分でpropsから値を取り出してしまっているためです。 この部分は、コンポーネントがレンダリングされる最初の1回のみでしか実行されません。

そのため、先ほどと同様以下のようにpropsを直接JSXで参照したり、

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <SubComponent value={count()}/>
    </>
  );
};

type SubComponentProps = {
  value: number;
};

const SubComponent: Component<SubComponentProps> = (props) => {

  return (
    <p>Count is: {props.value}</p>
  );
};

関数で包んであげたり必要があります。(この例は省略)

その他にもヘルパー(mergePropssplitProps)や、コストが掛かる計算にはcreateMemoを使うのも手のようです。 詳細は以下を参照ください。

ちなみに、サブコンポーネントに渡すpropsをAccessorのまま()をつけずに渡せば、分割代入でも正しく動作させることができます。

import type { Component, Accessor } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <SubComponent value={count}/>
    </>
  );
};

type SubComponentProps = {
  value: Accessor<number>;
};

const SubComponent: Component<SubComponentProps> = ({value}) => {

  return (
    <p>Count is: {value()}</p>
  );
};

ただし公式でこのやり方は紹介されていません。

()があったり無かったりで混乱を招きそう&ヘルパーが準備されているので、あまりこの方法はやらない方が良いのかもしれません。

useEffectの代わりにcreateEffect

副作用はcreateEffectで書けます。

import type { Component } from 'solid-js';
import { createSignal, createEffect } from 'solid-js';

export const SampleCounter: Component = () => {
  const [count, setCount] = createSignal(0);

  createEffect(() => {
    console.log("The count is now", count());
  });

  return (
    <>
      <button onClick={() => setCount(count() + 1)}>Click Me</button>
      <p>Count is: {count()}</p>
    </>
  );
};

依存するSignalは指定しなくても、埋め込まれたSignalを自動で読み込んでくれるようです。

ReactのuseEffectでは依存するstateを引数で指定する必要があったので、この点は便利そうですね。

リストサンプルを例に

制御フローというモノ

次はサンプルを変えて、以下のようなリスト表示するコンポーネントを題材にします。

SampleList.tsx

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleList: Component = () => {
  const [cats, setCats] = createSignal([
    { id: 'J---aiyznGQ', name: 'Keyboard Cat' },
    { id: 'z_AbfPXTKms', name: 'Maru' },
    { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' }
  ]);

  return (
    <>
      <ul>
        ...
      </ul>
    </>
  );
};

この...の部分にcatsを埋め込みたいとします。Reactの場合はmapを使ってこんな感じにすると思います。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';

export const SampleList: Component = () => {
  const [cats, setCats] = createSignal([
    { id: 'J---aiyznGQ', name: 'Keyboard Cat' },
    { id: 'z_AbfPXTKms', name: 'Maru' },
    { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' }
  ]);

  return (
    <>
      <ul>
        { cats().map( (cat, i) =>
          <li>
            {i}: {cat.name}
          </li>
        )}
      </ul>
    </>
  );
};

これでもきちんと表示できています。

この場合、リストが変更されたとき、最レンダリングはどのようになるでしょうか?確かめてみます。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';
import { JSX } from 'solid-js';

export const SampleList: Component = () => {
  const [cats, setCats] = createSignal([
    { id: 'J---aiyznGQ', name: 'Keyboard Cat' },
    { id: 'z_AbfPXTKms', name: 'Maru' },
    { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' }
  ]);

  const changeListHandler: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = () => {
    setCats([
        ...cats().slice(1, cats().length),
        cats()[0]
    ]);
  }

  return (
    <>
      <button onClick={changeListHandler}>Click Me</button>
      <ul>
        { cats().map( (cat, i) => {
          console.log(`rendered ${i}: ${cat.name}`);
          return (
            <li>
              {i}: {cat.name}
            </li>
          );
        })}
      </ul>
    </>
  );
};

リストを変更する用のボタンと、レンダリング時に実行されるconsole.logを実装しました。

ボタンを押すたびに以下がコンソールに表示され、各アイテムが再レンダリングされていることがわかります。

rendered 0: Maru
rendered 1: Henri The Existential Cat
rendered 2: Keyboard Cat
...

SolodJSではこういった場合に再レンダリングが発生しないように、制御フローを使用することができます。

試しにForという制御フローを使用した例が以下です。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';
import { JSX } from 'solid-js';
import { For } from 'solid-js';

export const SampleList: Component = () => {
  const [cats, setCats] = createSignal([
    { id: 'J---aiyznGQ', name: 'Keyboard Cat' },
    { id: 'z_AbfPXTKms', name: 'Maru' },
    { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' }
  ]);

  const changeListHandler: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = () => {
    setCats([
        ...cats().slice(1, cats().length),
        cats()[0]
    ]);
  }

  return (
    <>
      <button onClick={changeListHandler}>Click Me</button>
      <ul>
        <For each={cats()}>{ (cat, i) => {
          console.log(`rendered ${i()}: ${cat.name}`);
          return (
            <li>
              {i()}: {cat.name}
            </li>
          );
        }}</For>
      </ul>
    </>
  );
};

ボタンを押下すると、console.logが初回のみとなり、再レンダリングが行われていないことが分かります。

ただし、配列インデックスiがSignalのgetterになっていますので、i()としてアクセスする必要がある点は注意が必要です。

あとForと似たものとしてIndexという制御フローもあります。この違いについては長くなりそうなのでまた別途記事にしたいと思います。

テキスト入力サンプルを例に

onChangeではなくonInputを

またまた別のサンプルを準備します。

入力したテキストが即座に表示に反映されるよくやるアレです。

コードは以下のような感じです。

SampleTextInput.tsx

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';
import { JSX } from 'solid-js';

export const SampleTextInput: Component = () => {
  const [text, setText] = createSignal("default")

  const onChangeHandler: JSX.EventHandlerUnion<HTMLInputElement, Event> = (event) => {
    setText((event.target as HTMLInputElement).value)
  }

  return (
    <>
      <input type="text" onChange={onChangeHandler} />
      <p>Current input: {text()}</p>
    </>
  );
};

こんな感じで良いと思っていましたが、実際に動かすと、

Focusが外れるまで、Current input: の表示が古いままになってしまいました。

Reactに慣れていると、テキスト編集中(Focus中)の変更もonChangeで取得できていましたが、SolidJSの場合は以下のようにonInputを使う必要があります。

import type { Component } from 'solid-js';
import { createSignal } from 'solid-js';
import { JSX } from 'solid-js';

export const SampleTextInput: Component = () => {
  const [text, setText] = createSignal("default")

  const onChangeHandler: JSX.EventHandlerUnion<HTMLInputElement, Event> = (event) => {
    setText((event.target as HTMLInputElement).value)
  }

  return (
    <>
      <input type="text" onInput={onChangeHandler} />
      <p>Current input: {text()}</p>
    </>
  );
};

これはどちらかというと、Reactが標準から外れているようでして、ReactではonChangeonInputは同じ動きになっています。(逆にFocusが外れたことは、onBlurで取得する必要があります。)

その他

createSignalはどこにでも書ける

こちらはサンプルを準備できませんでしたが、Signalはコンポーネント内外問わずどこにでも記述することができます。(ReactのuseStateなどのHookはコンポーネント内に記述が必要でした)

これにより、Contextなどを使わなくてもグローバルな状態管理を簡単に書くことができます。 (実際には、SolidJSにもContextがあるので、状況に応じて使い分けが必要なようです)

まとめ

いかがでしたでしょうか?再レンダリングを抑制するのに凝れる作りで、いいなぁ~~という印象を受けました。

他にも便利な部分がたくさんあり紹介したい部分があったのですが、1本の記事にするには長くなりそうでしたのでここまでにしたいと思います。 ForIndexの違いやcreateStoreなども便利そうでしたので、また次回以降も紹介したいと思います。