valid,invalid

関心を持てる事柄について

ReactのContextとHooksで日本語のふりがな入力を支援するコンポーネント書いた

(2019-09-22追記) この記事の内容と公開したライブラリはdeprecatedとしました。詳しくは ReactのuseStateで日本語のふりがな入力を支援するhook書いた - valid,invalid 参照

漢字を入力したときにふりがなを自動入力する機能をサポートするためのReact componentを書いてみました。日本語のフォームでよくあるやつです。

demo

github.com

www.npmjs.com

最近は仕事でReactをあまり書いておらず16以降のContextとか16.8のHooksとか新し目の機能のことがわかっていなかったので、その辺で遊びたかったのがモチベーションです。

Hooks と言っていますが目玉ぽい useState の話でなく useReducer, useContext がメインです。

実装

使いかたはREADMEに書いたのでさらっと流します。以下のような感じで Kana(Provider|Dispatcher|Consumer)react-kana-provider が提供します。provider, consumer という名前からわかるとおり React Context を使っています。

import * as React from "react";
import * as ReactDOM from "react-dom";
import {
  KanaProvider,
  KanaDispatcher,
  KanaConsumer,
  KanaDispatcherProps,
  KanaConsumerProps
} from "react-kana-provider";

const App = () => (
  <KanaProvider fieldNames={["last_name"]}>
    <KanaDispatcher>
      {({ setKana }: KanaDispatcherProps) => (
        <input
          type="text"
          onChange={e => setKana("last_name", e.target.value)}
        />
      )}
    </KanaDispatcher>
    <KanaConsumer>
      {({ kana }: KanaConsumerProps) => (
        <input
          type="text"
          value={kana.last_name}
         />
      )}
    </KanaConsumer>
  </KanaProvider>
  );
);

ReactDOM.render(<App />, document.getElementById("root"));

実践: React Hooks - mizchi's blogに書かれている「useReducerとcontextを組み合わせてReduxのようにstateを管理する」というのを内部ではやっています。provider、consumerだけでなくdispatcherという名前のcomponentを提供しているのはそのためです。(実態はdispatcherもconsumerです)

漢字を入力するフィールド側(KanaDispatcher)で「漢字を入力する」イベントをdispatchし、内部のstateが更新され、ふりがなを入力するフィールド(KanaConsumer)側でそれを参照する流れです。

入力された文字列からひらがなを抽出するロジックはhistorykanaに丸投げです。

また、ふりがなフィールド側に値をどういう条件で・どのタイミングでセットするかは完全に利用者に委ねています。(ふりがなフィールドが入力済かどうかを検知したりはしない)

原形: context のみで書く

最初はuseReducerなどは使わずcontextだけでなんとかしようと、providerをラップした React.Component をがりがり書いていました。

import * as React from "react";
import historykana from "historykana";

const KanaContext = React.createContext("");

export class KanaProvider extends React.Component {
  constructor(props) {
    // 省略
  }

  setKana(fieldName, inputtedValue) {
    this.setState((state, props) => {
      const history = inputtedValue
        ? [...state.history[fieldName], inputtedValue]
        : [];

      return {
        ...state,
        history: {
          ...state.history,
          [fieldName]: history
        },
        kana: {
          ...state.kana,
          [fieldName]: historykana(history)
        }
      };
    });
  }

  render() {
    return (
      <KanaContext.Provider value={this.state.kana}>
        {this.props.children({
          setKana: this.setKana,
          kana: this.state.kana
        })}
      </KanaContext.Provider>
    );
  }
}

export const KanaConsumer = ({ children }) => (
  <KanaContext.Consumer>{kana => children({ kana })}</KanaContext.Consumer>
);

これはこれで動くのですが遊び足りない React.Component を使わなくなっていく流れに沿ってないなーと思ったり、ライブラリ利用者側では consumer は 切り離せるのに state を更新する setKana 関数は利用する component まで props で渡していくのが不均衡だなと思ったりしました。

現状: useReducer + context で書く

現在の react-kana-provider の実装を一部省略しつつ貼ります。(上記実装との間にミッシングリンクがあり、突然 TypeScript になります)

import * as React from 'react';
import historykana from 'historykana';

function reducer(state: KanaProviderState, action: any) {
  switch (action.type) {
    case 'SET_KANA': {
      const { inputtedValue, fieldName } = action;
      const history = inputtedValue
        ? [...state.history[fieldName], inputtedValue]
        : [];

      return {
        ...state,
        history: {
          ...state.history,
          [fieldName]: history,
        },
        kana: {
          ...state.kana,
          [fieldName]: historykana(history),
        },
      };
    }
    default: {
      return state;
    }
  }
}

const KanaContext = React.createContext<KanaProviderState>(null as any);
const DispatchContext = React.createContext<React.Dispatch<any>>(null as any);

export const KanaProvider: React.FunctionComponent<{
  fieldNames: string[];
}> = ({ fieldNames, children }) => {
  const [state, dispatch] = React.useReducer(
    reducer,
    getInitialState(fieldNames),
  );
  return (
    <KanaContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </KanaContext.Provider>
  );
};

export const KanaDispatcher: React.FunctionComponent = ({ children }) => {
  const dispatch = React.useContext(DispatchContext);
  const setKana = (fieldName: string, inputtedValue: string) =>
    dispatch({ type: 'SET_KANA', fieldName, inputtedValue });
  return typeof children === 'function' ? children({ setKana }) : null;
};

export const KanaConsumer: React.FunctionComponent = ({ children }) => {
  const { kana } = React.useContext(KanaContext);
  return typeof children === 'function' ? children({ kana }) : null;
};

すっきりしています。記述量はさほど変わっていないので一見これ本当にすっきりしてんのかという感じですが、state を更新する責務が reducer にまとめられたりしているため、Redux 利用者ならさらっと読み下せるようになったのではと感じます。 export している component がすべて React.FunctionComponent に置き換えられているのも良さそうです。

dispatcher を提供しているので prop drilling 問題も解消してます。

CodeSandbox

これまで使っていなかったのですがアイデアを試したいときに超絶便利ですね。自分はフロントエンドの環境構築能力がダメダメなので一瞬で立ち上げてもらえて非常に助かりました。

f:id:ohbarye:20190210112250p:plain

CodeSandbox上で「ライブラリ側」と「利用者側」のコードを同時に編集できるのも実験がしやすくて最高です。ライブラリが提供するAPIについてインタラクティブに試行錯誤したりできます。

今回は「よーしライブラリ書くぞ!」と意気込んで始めたというより、CodeSandbox上で「こんなことできるかな」といろいろ試してたらなんかできたので面白かった、公開しておくか、という流れでした。

所感

久々に触ったらいろいろ学びがあってよかったですが、逆にまだまだわからないことがいっぱいあるなと思いました。React + TypeScript にまだ身体が順応しきってなくてけっこう手が止まる。

また、npm publish したものの hooks 使っているので16.8以上じゃないと使えない、おまえ使ってもらう気あるのかという感じですが一旦は自分のためのコードということで。

余談

実はけっこう前にこういうのを書こうとしたけど完全に記憶から消えていた、というのを思い出した。以前に jquery.autokana というライブラリを使っていて、その repository の issue で「オレもいつか React で書いてみるわ!」とか宣言していた…!

(1年以上かけて伏線を回収した感がある)

わかっていないこと

このあと調べたり詳しい人に聞いたりしたい

そもそもこのやりかたでよいのか

わざわざ context とか hooks 使わなくても render props とか HOC でも書けるので、解決したい問題に対してベストなアプローチなのかわかってない

実験の仕方

CodeSandboxでごりごり書いて実装したけど、世の中で React の component ライブラリ書いている人はどうやって開発・実験・テストしているのか

hooks の polyfill

React context には create-react-context という polyfill があるが、hooksにもあるのかな?もしあれば対応する React の version を下げられる