petitviolet_blog

@petitviolet blog

Reactのカスタムhookで作るフォーム

何番煎じかわからないが実装してみたから残しておく。

やりたいこと

  • Reactのカスタムhookとして実装する
  • フォームのバリデーションをする
  • フォームをなるべく共通化する
    • onSubmitとかonChangeについては共通化する
    • JSXやスタイルについては共通化せず個別に実装できるようにする

流れとしてはReact hooksを自分で定義してフォームをそれっぽく扱えるようにし、そのhookを使ってログインフォームを実装する。

バージョンとか

package.jsonからTypeScript, Reactあたりを抜粋するとこんな感じ

{
  "dependencies": {
    "@types/react": "^16.9.1",
    "@types/react-dom": "^16.8.5",
    "@types/react-router-dom": "^5.1.2",
    "react": "^16.9.0",
    "react-dom": "^16.9.0",
    "react-router-dom": "^5.1.2",
    "typescript": "^3.7.2",
    ...
}

カスタムhookの実装

まずカスタムhookとしてuseFormを実装する。 フォームとしてスタイル以外のところ、Stateの管理やバリデーションなどを引き受けるコンポーネントとなる。

面倒なのでコード全体にコメントで説明。

import * as React from 'react';

// バリデーションを実行するためのI/F
export interface Validator<State extends {}> {
  runAll(state: State): Errors;
}

// バリデーションエラーをMapで保持する
export type Errors = Map<string, string>;

// カスタムhook
// - submit時に実行する関数
// - 初期state
// - バリデーション関数
export const useForm = <State extends {}>(onSubmit, initial: State, validator: Validator<State>) => {
  // 入力状態
  const [state, setState] = React.useState<State>(initial);
  // バリデーションエラー
  const [errors, setErrors] = React.useState<Errors>(new Map());
  // submitボタンが有効かどうか
  const [submitEnabled, submitEnable] = React.useState<boolean>(false);

  const isValid: () => boolean = React.useCallback(() => {
    // バリデーションを実行
    const _errors = Array.from(validator.runAll(state)).reduce<Map<string, string>>(
      (map, [key, error]) => {
        if (error) {
          return map.set(key, error);
        } else {
          return map;
        }
      },
      new Map(), // reduceの初期値
    );
    setErrors(_errors); // stateの更新
    return _errors.size == 0;
  }, [state]); 

  // submitボタンが押されたときの挙動
  const handleSubmit = React.useCallback(
    event => {
      if (event) event.preventDefault();

      if (submitEnabled && isValid()) {
        // validな入力ならsubmit可
        onSubmit(state);
      } else {
        console.log(`submit is disabled. error = ${errors}`);
      }
    },
    [state],
  );

  // 入力に変更があったときの挙動
  const handleChange = React.useCallback(
    event => {
      // 入力された値をeventから取り出す
      const name: string = event.target.name;
      const value: string = event.target.value;
      if (!submitEnabled && value.length > 0) {
        // 何かしらフォームに入力がなされているとsubmitボタンを押せるようにする
        submitEnable(true);
      }
      // stateを更新する
      setState(prevState => {
        const newState = { ...prevState, [name]: value };

        return newState;
      });
    },
    [state],
  );

  // useFormの提供するI/F
  return {
    state, // 入力された値
    errors, // バリデーションエラー
    disabled: !submitEnabled, // submit可能かどうか
    handleChange, // 入力が変更された場合のcallback
    handleSubmit, // submitされた場合のcallback
  };
};

一部のhooksでは引数として依存する値を配列で渡すことができ、大体はstateが変わらなければ結果は同じなので[state]を渡していたりする。

どうやって使うか

たとえばLoginフォーム。 こちらもコードで説明。

import * as React from 'react';
import {Redirect} from 'react-router-dom';
import {Errors, useForm, Validator} from './useForm';

// Loginフォームの状態
type LoginState = {
  readonly email: string;
  readonly password: string;
};

// useFormで要求されるフォームのバリデーション関数たち
const validator: Validator<LoginState> = new (class implements Validator<LoginState> {
  emailValidator(email: string): string | null {
    if (email.length == 0) {
      return 'Email is empty.';
    } else if (!email.match(/.+@.+\..+/)) {
      return 'Email is not valid format';
    }
  }

  passwordValidator(password: string): string | null {
    if (password.length == 0) {
      return 'Password is empty.';
    } else if (password.length < 8) {
      return 'Password min length is 8';
    } else if (password.length > 100) {
      return 'Password max length is 100';
    }
  }

  runAll(state: LoginState): Map<string, string> {
    return new Map([
      ['email', this.emailValidator(state.email)],
      ['password', this.passwordValidator(state.password)],
    ]);
  }
})();

const Login = () => {
  // ログイン状態の保持
  const [loggedIn, setLoggedIn] = React.useState(false);

  if (loggedIn) {
    // すでにloginしていたらリダイレクトする
    return <Redirect to={"/"} />;
  }

  // submit時のログイン処理
  const onSubmit = values => {
    if (values.email && values.password) {
      ApiClient // 外部APIを叩いている想定
        .login(values.email, values.password)
        .then(response => {
          setLoggedIn(true);
        })
        .catch(err => {
          console.log(`Failed to login. message = ${err}`);
        });
    } else {
      console.log('Invalid inputs');
    }
  };

  // useFormを使ったフォームの状態管理
  const { state, errors, disabled, handleChange, handleSubmit } = useForm<LoginState>(
    onSubmit, // submit時の処理
    { // 初期値
      email: '',
      password: '',
    },
    validator, // バリデーション関数たち
  );

  // フォームのJSX(スタイル)
  return (
    <form onSubmit={handleSubmit}>
      <Label>Email:
        <Input
            key={'email'}
            name={'email'}
            type={'email'}
            value={state.email}
            onChange={handleChange} />
        <div>{errors.get('email')}</div>
      </Label>
      <Label>Password:
        <Input
            key={'password'}
            name={'password'}
            type={'password'}
            value={state.password}
            onChange={handleChange} />
        <div>{errors.get('password')}</div>
      </Label>
      <button type="submit" disabled={disabled}>送信</button>
    </form>
  );
};

export default Login; 

これでログインフォームは動く、はず。 とはいえ認証した結果をLoginコンポーネント内だけで持っているのは流石に実用出来ないが。

さっくりまとめると、

  • useFormの責務
    • formの状態管理
    • submit時のバリデーションとエラーの保持
  • useFormを使う側の責務
    • JSX(スタイル含む)
    • onChange/onSubmitを適切に設定する
    • バリデーション関数の実装
    • submit時の処理(APIリクエストなど)

という感じ。 formに入力されている値の保持とかバリデーション実行とかはよくあることなので共通化してあると考える頻度が減って良い。

近況

2019/07/01にArm Treasure Data株式会社に入社していました。

丸4ヶ月経ったところです。

経緯とか

転職を考えていた頃にタイミングよくスカウトメールが来てオッてなって応募してみたら受かってしまったという流れです。 スカウトメールきっかけで転職が決まることがあるもんですね。 とはいえ話聞くまではサービスとかもよくわかってなくて、インターネット上の存在として知っている人が沢山いてOSSも作っててなんかビッグデータ()扱ってる会社なんでしょ、という雑な認識でした。

入社を決めた理由は色々ありますが、雑に箇条書きにするとこのあたり。

  • でかいデータを扱うサービスをやってみたかった
  • 自分の持っていないスキルを持った人がたくさんいる
  • ユニークな経験ができそう
  • 多拠点、英語

何してるの

私はCDPのAPI開発チームに加わって今のところほぼRuby(Rails)を書いてます。 Arm Treasure Data、2019年Marketing Technology Awardsの最優秀カスタマーデータプラットフォームに選出 - Treasure Data

バックエンドにPrestoやHiveやWorkflow(digdag)等があったりマイクロサービスっぽいアーキテクチャだったりするサービスです。

チームのおよそ半分がカナダのバンクーバーで働いていて、9月にはバンクーバーにチームみんな集まるべく出張してきたりもしました。 が、ぶっちゃけ英語がボトルネックなので頑張らないと死んでしまいます。 出張の感想

その他、裁量労働やらランチやらFlexPotやら福利厚生も充実していて良い環境でありがたい限り。

お約束

お待ちしています。連絡ください

fzfで快適なターミナルライフ

以前こんなエントリを書いた。 petitviolet.hatenablog.com

が、最近はpecoからfzfに大体を移行している。

主な理由はfzfについているpreview機能が気に入ったからで、ファイルやGitをインタラクティブに選択する際にpreviewが出来ると助かることが多いため。
逆にpreview機能を必要としないhistory検索とかはpecoを使っているままだったりはする。

どんな感じになるのか

fzfの--previewオプションを使って便利になる

fzf preview demo
fzfでいい感じな様子

事前準備とか

環境

macOS Mojave 10.14とZsh

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14
BuildVersion:   18A391
$ zsh --version
zsh 5.7 (x86_64-apple-darwin17.7.0)
$ fzf --version
0.18.0 (brew)

Zsh Line Editorについて

fzf関係なく、zshで関数に対するショートカットキーを設定したり入力中のバッファをいじるのにZLE(Zsh Line Editor)を使っている。

だいたい↓のあたり。

zle accept-line
zle clear-screen
zle -N my_function
bindkey '<key>' my_function

BUFFER='xxx'
LBUFFER+='yyy'
CURSOR=$#LBUFFER

詳しくはman zshzleすると読める。

fzfのグローバル設定

FZF_DEFAULT_OPTSは以下のようにしている。

export FZF_DEFAULT_OPTS="--no-sort --exact --cycle --multi --ansi --reverse --border --sync --bind=ctrl-t:toggle --bind=ctrl-k:kill-line --bind=?:toggle-preview --bind=down:preview-down --bind=up:preview-up"

まあこれはお好みで。

ディレクトリ移動

zshについてくるcdrコマンドを使っていい感じにディレクトリ移動をする。

lsに適当なオプションをつけて実行した結果をpreviewで表示している。 あるファイルを探しているけど、どこにあったっけ、と思いながらインタラクティブに選ぶことが出来て便利。

# lsみながらcdrする
function select_cdr(){
    local selected_dir=$(cdr -l | awk '{ print $2 }' | \
      fzf --preview 'f() { sh -c "ls -hFGl $1" }; f {}')
    if [ -n "$selected_dir" ]; then
        BUFFER="cd ${selected_dir}"
        zle accept-line
    fi
    zle clear-screen
}
zle -N select_cdr
bindkey '^@' select_cdr

treeコマンドからファイルを選択する

現在のディレクトリ配下からファイルを選択したいけどScalaとか書いてるとディレクトリ構造が深くてめんどくさい、という時にtreeで一覧表示しつつ、そのファイル名を選択できるようにした。

headでファイルの先頭をpreviewしている。 ignoreしないと死んでしまうとは思うので、そこは適当に付け足したりする必要がありそう。

# treeの一覧からheadしながらファイルを選択する
function tree_select() {
  tree -N -a --charset=o -f -I '.git|.idea|resolution-cache|target/streams|node_modules' | \
    fzf --preview 'f() {
      set -- $(echo -- "$@" | grep -o "\./.*$");
      if [ -d $1 ]; then
        ls -lh $1
      else
        head -n 100 $1
      fi
    }; f {}' | \
      sed -e "s/ ->.*\$//g" | \
      tr -d '\||`| ' | \
      tr '\n' ' ' | \
      sed -e "s/--//g" | \
      xargs echo
}

# treeで選択したファイル名を入力する
function tree_select_buffer(){
  local SELECTED_FILE=$(tree_select)
  if [ -n "$SELECTED_FILE" ]; then
    LBUFFER+="$SELECTED_FILE"
    CURSOR=$#LBUFFER
    zle reset-prompt
  fi
}
zle -N tree_select_buffer
bindkey "^t" tree_select_buffer

これを少し応用すると選択したファイルを直接vimで開く、みたいなことも可能。

# treeから選択したファイルをvimで開く
function open_from_tree_vim(){
  local selected_file=$(tree_select)
  if [ -n "$selected_file" ]; then
    BUFFER="vim $selected_file"
  fi
  zle accept-line
}
zle -N open_from_tree_vim
bindkey "^v^t" open_from_tree_vim

docker psからプロセスを選択する

docker psした結果からイメージを選択してstopしたりlogを見たりする時に使う。

# docker psからdocker log見ながらnameを選ぶ
function select_docker_process(){
    LBUFFER+=$(docker ps -a --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Ports}}\t{{.Networks}}' | \
      fzf --preview-window=down --preview 'f() {
          set -- $(echo -- "$@")
          if [[ $3 != "ID" ]]; then
            docker logs --tail 300 $3
          fi
        }; f {}' | \
      awk -F '\t' '{ print $2 }' | \
      tr "\n" " ")
    CURSOR=$#LBUFFER
    zle reset-prompt
}
zle -N select_docker_process
bindkey "^g^d^h" select_docker_process 

docker ps --format 'table ...'が便利でよかった。

git statusで変更のあるファイルから選択する

git statusした結果からファイルを選択してgit addしたりする時に使う。 プレビューでgit diffが表示されるようにしてある。

untrackedなファイルや既にgit addしたファイルとかについてもgit diffで差分が見れるようにしたかった面倒な実装になってしまった。

# git statusで対象となるファイルのgit diffみながらファイルを選択する
function select_file_from_git_status() {
  git status -u --short | \
    fzf -m --ansi --reverse --preview 'f() {
      local original=$@
      set -- $(echo "$@");
      if [ $(echo $original | grep -E "^M" | wc -l) -eq 1 ]; then # staged
        git diff --color --cached $2
      elif [ $(echo $original | grep -E "^\?\?" | wc -l) -eq 0 ]; then # unstaged
        git diff --color $2
      elif [ -d $2 ]; then # directory
        ls -la $2
      else
        git diff --color --no-index /dev/null $2 # untracked
      fi
    }; f {}' |\
    awk -F ' ' '{print $NF}' |
    tr '\n' ' '
}

# ↑の関数で選んだファイルを入力バッファに入れる
function insert_selected_git_files(){
    LBUFFER+=$(select_file_from_git_status)
    CURSOR=$#LBUFFER
    zle reset-prompt
}
zle -N insert_selected_git_files
bindkey "^g^s" insert_selected_git_files

# ↑の関数で選んだファイルをgit addする
function select_git_add() {
    local selected_file_to_add="$(select_file_from_git_status)"
    if [ -n "$selected_file_to_add" ]; then
      BUFFER="git add $(echo "$selected_file_to_add" | tr '\n' ' ')"
      CURSOR=$#BUFFER
    fi
    zle accept-line
}
zle -N select_git_add
bindkey "^g^a" select_git_add

git branchとtagから選択する

git branchgit tagの両方から、branch/tagを選択してcheckoutしたりpush/pullする時に使う。 主にgitのformatを設定するのが面倒くさかった。

# git branchとgit tagの結果からgit logを見ながらbranch/tagを選択する
function select_from_git_branch() {
  local list=$(\
    git branch --sort=refname --sort=-authordate --color --all \
      --format='%(color:red)%(authordate:short)%(color:reset) %(objectname:short) %(color:green)%(refname:short)%(color:reset) %(if)%(HEAD)%(then)* %(else)%(end)'; \
    git tag --color -l \
      --format='%(color:red)%(creatordate:short)%(color:reset) %(objectname:short) %(color:yellow)%(align:width=45,position=left)%(refname:short)%(color:reset)%(end)')

  echo $list | fzf --preview 'f() {
      set -- $(echo -- "$@" | grep -o "[a-f0-9]\{7\}");
      [ $# -eq 0 ] || git --no-pager log --oneline -100 --pretty=format:"%C(red)%ad%Creset %C(green)%h%Creset %C(blue)%<(15,trunc)%an%Creset: %s" --date=short --color $1;
    }; f {}' |\
    sed -e 's/\* //g' | \
    awk '{print $3}'  | \
    sed -e "s;remotes/;;g" | \
    perl -pe 's/\n/ /g'
}

# ↑の関数で選んだbranch/tagを入力バッファに入れる
function select_to_insert_branch() {
    LBUFFER+=$(select_from_git_branch)
    CURSOR=$#LBUFFER
    zle reset-prompt
}
zle -N select_to_insert_branch
bindkey "^g^o" select_to_insert_branch

# ↑の関数で選んだbranch/tagにgit checkoutする
function select_git_checkout() {
    local selected_file_to_checkout=`select_from_git_branch | sed -e "s;origin/;;g"`
    if [ -n "$selected_file_to_checkout" ]; then
      BUFFER="git checkout $(echo "$selected_file_to_checkout" | tr '\n' ' ')"
      CURSOR=$#BUFFER
    fi
    zle accept-line
}
zle -N select_git_checkout
bindkey "^gco" select_git_checkout

まとめ