ENECHANGE Developer Blog

ENECHANGE開発者ブログ

RenderProps パターンで UI 依存のないコンポーネントライブラリを作る

はじめに

弊社では、自社プロダクトの開発で培ったノウハウを活かし、電力事業者向けに料金シミュレーション機能を提供しています。 ここ最近では、共通のシミュレーション Rails API とクライアントごとにカスタマイズされた React ベースの SPA を組み合わせた構成が主流です。 シミュレーションの基本的なロジックは共通していますが UI に関してはクライアントごとに多様な要望があり、これまでは既存のリポジトリをフォークし UI 部分を個別にカスタマイズするアプローチを取っていました。

しかし、この方法には以下のような問題がありました:

  • 同じようなコードがリポジトリを跨いで量産されてしまう
  • 共通ロジックに変更があった場合、全てのリポジトリに反映する必要がある
  • 同じバグを複数のリポジトリで修正する必要がある

これらの問題を解決するために RenderProps パターンを用いた UI とロジックを明確に分離したコンポーネントライブラリを作成しました。

RenderProps とは

RenderProps は React におけるコンポーネント設計パターンの一つで以下のような特徴を持ちます。

  • RenderProps コンポーネントの props(多くの場合 childrenrender という名前)でコンポーネントを返す関数を受け取る
  • RenderProps コンポーネント自体はUIをレンダリングせずに上記関数の戻り値をレンダリングする
  • RenderPorps コンポーネントの実装に必要な情報は上記関数の引数として渡される

このパターンの最大の利点は 機能(ロジック)とUI(見た目)を完全に分離できる ことです。 これにより、同じロジックを持ちながら、異なるUIを持つコンポーネントを簡単に作成できます。

簡単な例で説明すると:

// RenderPropsパターンを使用したコンポーネント
const Counter = ({ children }) => {
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  
  // childrenは関数で、カウント値と操作関数を引数として渡す
  return children({
    count,
    increment,
    decrement
  });
};

// 使用例
<Counter>
  {({ count, increment, decrement }) => (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )}
</Counter>

この例では Counter コンポーネントはカウント状態とその操作関数を提供しますが UI は提供しません。 UI は Counter を使用する側が自由に決定できます。

実装例

電気料金のシミュレーションでは全プロダクト共通で郵便番号の入力と、入力した郵便番号で API を叩きそのエリアで対象となる電力プランを取得する機能があります。 郵便番号入力は input のスタイルや7桁での入力、3桁4桁での入力といった UI の指定が事業者ごとにあります。

構成

今回は以下のような構成になりました。

PostCodeInput, SeparatedPostCodeInput

パッケージ利用時に呼び出されることを想定したコンポーネント。

PostCodeInputCore

郵便番号入力完了時に API を叩く、という共通機能を提供するコンポーネント。

NumberInput

数値の入力を行うための入力制御機能を提供するコンポーネント。 郵便番号以外にも数値入力コンポーネントが複数ありそれらで使いまわす想定。

実際のコード

コードは以下のようになりました(一部簡略化しています)

code

PostCodeInput

export interface PostCodeInputProps {
  value?: string;
  onChange?: (value: string) => void;
  children: (props: PostCodeInputRenderProps) => ReactNode;
  sendApiRequest?: boolean;
}

export interface PostCodeInputRenderProps { 
  onChange: (value: string) => void;
  onCompositionStart: CompositionHandler;
  onCompositionEnd: CompositionHandler;
  apiResults: ApiResult;
}

const PostCodeInput: FC<PostCodeInputProps> = ({
  value = '',
  onChange = (_) => {},
  children,
  sendApiRequest = true,
}) => {
  const PostCodeInputCoreChildren = (postCodeInputCoreRenderProps: PostCodeInputCoreRenderProps) => {
    
    const NumberInputChildren = (props: NumberInputRenderProps) => {
      return children( {
        ...props,
        apiResults: postCodeInputCoreRenderProps.apiResults,
      });
    }

    return (
      <NumberInput
        value={value}
        maxLength={7}
        onChange={onChange}
        onInputComplete={postCodeInputCoreRenderProps.onChange}
      >{NumberInputChildren}</NumberInput>
    )
  }

  return (
    <PostCodeInputCore
      onChange={onChange}
      sendApiRequest={sendApiRequest}
    >{PostCodeInputCoreChildren}</PostCodeInputCore>
  )
};

export default PostCodeInput;

SeparatedPostCodeInput

export interface InputSlotProps {
  value: string;
  ref: React.RefObject<HTMLInputElement | null>;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  onCompositionStart: CompositionHandler;
  onCompositionEnd: CompositionHandler;
  onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
}

export interface SeparatedPostCodeInputProps {
  leftValue?: string;
  rightValue?: string;
  onChange?: (leftValue: string, rightValue: string) => void;
  sendApiRequest?: boolean;
  slots: {
    left: (props: InputSlotProps) => ReactNode;
    right: (props: InputSlotProps) => ReactNode;
  };
  children: (props: SeparatedPostCodeInputRenderProps) => ReactNode;
}

export interface SeparatedPostCodeInputRenderProps {
  leftInput: ReactNode;
  rightInput: ReactNode;
  apiResults: ApiResult;
}

const SeparatedPostCodeInput: FC<SeparatedPostCodeInputProps> = ({
  leftValue = '',
  rightValue = '',
  onChange = () => {},
  sendApiRequest = true,
  slots,
  children,
}) => {
  const leftInputRef = useRef<HTMLInputElement>(null);
  const rightInputRef = useRef<HTMLInputElement>(null);
  
  const handleRightKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if ((e.key === 'Backspace' || e.key === 'Delete') && rightValue === '' && leftInputRef.current) {
      leftInputRef.current.focus();
    }
  };

  const renderPostCodeInputCore = (coreProps: PostCodeInputCoreRenderProps) => {

    const handlePostCodeChange = (postcode: string) => {
      coreProps.onChange(postcode);
    }

    const handleLeftInputComplete = (newLeftValue: string) => {
      onChange(newLeftValue, rightValue);
      handlePostCodeChange(newLeftValue + rightValue);
      
      if (newLeftValue.length === 3 && rightInputRef.current) {
        rightInputRef.current.focus();
      }
    };

    const handleRightChange = (newRightValue: string) => {
      onChange(leftValue, newRightValue);
      handlePostCodeChange(leftValue + newRightValue);
    };

    const leftInput = (
      <NumberInput
        value={leftValue}
        maxLength={3}
        onChange={(str) => onChange(str, rightValue)}
        onInputComplete={handleLeftInputComplete}
      >
        {({ onChange, ...props }) => slots.left({
          ...props,
          value: leftValue,
          onChange: (e) => onChange(e.target.value),
          ref: leftInputRef,
        })}
      </NumberInput>
    );

    const rightInput = (
      <NumberInput
        value={rightValue}
        maxLength={4}
        onChange={handleRightChange}
        onKeyDown={handleRightKeyDown}
      >
        {({ onChange, ...props }) => slots.right({
          ...props,
          value: rightValue,
          onChange: (e) => onChange(e.target.value),
          ref: rightInputRef,
        })}
      </NumberInput>
    );

    return children({
      leftInput,
      rightInput,
      apiResults: coreProps.apiResults,
    });
  };
  
  return (
    <PostCodeInputCore
      sendApiRequest={sendApiRequest}
    >
      {renderPostCodeInputCore}
    </PostCodeInputCore>
  );
};

export default SeparatedPostCodeInput;

PostCodeInputCore

export interface PostCodeInputCoreProps {
  onChange?: (value: string) => void;
  children: (props: PostCodeInputCoreRenderProps) => ReactNode;
  sendApiRequest?: boolean;
}

export interface PostCodeInputCoreRenderProps {
  onChange: (value: string) => void;
  apiResults: ApiResult;
}

const PostCodeInputCore: FC<PostCodeInputCoreProps> = ({
  onChange = (_) => {},
  children,
  sendApiRequest = true,
}) => {
  const [apiResult, setApiResult] = React.useState<ApiResult | undefined>();

  const handleChange = (postcode: string) => {
    onChange(postcode);
    if (sendApiRequest && postcode.length === 7) {
      callApi(postcode).resolve((result) => setApiResult(result));
    }
  }

  const renderProps = {
    onChange: handleChange,
    apiResult,
  }
  
  return children(renderProps);
};

export default PostCodeInputCore;

NumberInput

export interface NumberInputProps {
  value?: string;
  maxLength?: number;
  onInputComplete?: (value: string) => void;
  onChange?: (value: string) => void;
  onCompositionStart?: (param: React.CompositionEvent) => void;
  onCompositionEnd?: (param: React.CompositionEvent) => void;
  children: (props: NumberInputRenderProps) => ReactNode;
}

export type CompositionHandler = ((param: React.CompositionEvent) => void);
export interface NumberInputRenderProps {
  value: string;
  isComposing: boolean;
  onChange: (value: string) => void;
  onCompositionStart: CompositionHandler;
  onCompositionEnd: CompositionHandler;
}

const NumberInput = ({
  value = '',
  maxLength,
  onInputComplete = () => {},
  onChange = () => {},
  onCompositionStart: onCompStart = () => {},
  onCompositionEnd: onCompEnd = () => {},
  children,
}: NumberInputProps) => {
  const { isComposing, onCompositionStart, onCompositionEnd } = useComposition();
  const [completedValue, setCompletedValue] = React.useState(value);

  const handlInputComplete = (str: string) => {
    const sliced = maxLength !== undefined ? str.slice(0, maxLength) : str;
    const isValueChanged = sliced !== completedValue;
    const v = isValueChanged ? sliced : completedValue;

    onChange(v);
    if (isValueChanged) {
      setCompletedValue(v);
      onInputComplete(v);
    }
  }

  const handleChange = (str: string) => {
    if (isComposing) {
      onChange(str);
    } else {
      handlInputComplete(normalizeNumberInput(str));
    }
  };

  const handleCompositionStart = (param: React.CompositionEvent) => {
    onCompositionStart();
    onCompStart(param);
  }

  const handleCompositionEnd = (param: React.CompositionEvent) => {
    onCompositionEnd(param, (e) => {
      const str = (e.target as HTMLInputElement).value;
      handlInputComplete(normalizeNumberInput(str));
      onCompEnd(param);
    });
  };

  const renderProps = {
    value,
    isComposing,
    onChange: handleChange,
    onCompositionStart: handleCompositionStart,
    onCompositionEnd: handleCompositionEnd,
  };
  return <>{children(renderProps)}</>;
};

export default NumberInput;

使用例

実際にコンポーネントを利用する際は以下のようになります。 今回は MUI の UI をそのまま使いましたが UI 部分は自由に実装することができます。

import { PostCodeInput, type ApiResult } from "emap-simulation-tools";
import { useState } from "react";
import TextField from '@mui/material/TextField';

type PostCodeInputProps = {
  value?: string;
  onChange?: (value: string) => void;
}

const getErrorMessage = (result: ApiResult) => {
  if (result === undefined) return;

  if (result.status === 'error') {
    return '郵便番号の取得に失敗しました。';
  } else if (result.data?.length === 0) {
    return '郵便番号に対応する電気料金プランが見つかりませんでした。';
  }
}

const PostCodeInpuDemo = ({ 
  value = '',
  onChange = () => {}, 
}: PostCodeInputProps) => {
  const [postCode, setPostCode] = useState(value);

  const handleChange = (newValue: string) => {
    setPostCode(newValue);
    onChange(newValue);
  };

  return <EmapPostCodeInput
    value={postCode}
    onChange={handleChange}
  >{
    ({ onChange, apiResults, onCompositionStart, onCompositionEnd }) => {
      const errorMessage = getErrorMessage(apiResults);
      return <TextField
        label="郵便番号"
        value={postCode}
        onChange={(e) => onChange(e.target.value)}
        onCompositionStart={onCompositionStart}
        onCompositionEnd={onCompositionEnd}
        error={!!errorMessage}
        {...errorMessage ? { helperText: errorMessage } : {}}
      />
    }
  }
  </EmapPostCodeInput>
}

export default PostCodeInpuDemo;

最後に

今回は RenderProps コンポーネントの実装について書きましたが、まだ RenderPorps コンポーネントを本格的に導入したプロジェクトは開始できていません。 今後のシミュレーションサイトの構築の際に実際の使い心地を試せたらなと思っています。

個人的に以下のような効果を期待しています。

  • 初期開発期間の短縮
  • 共通機能部分での機能追加時の横展開工数短縮(パッケージを更新して各リポジトリでパッケージバージョンアップ)

また具体的な成果がでたら続きを書こうと思います。