TR;DR

  • useContext は、階層の深いコンポーネントに state を渡す場面で使うと良い
  • useReducer は、state の変更パターンが多い場面で使うと良い
  • useContext + useReducer は、state を使うコンポーネントの階層が深い上に、前回の state を元に新しい状態を作る場面で使うと良い

useContextだけを使うケース

useContext は React の組み込みの Hooks の1つです。Provider でラップしたコンポーネントのツリーのどこからでも、同一の Context Object を参照できるようにする Hook です。

useContext は Context を通じて子や孫以下のコンポーネントで同一の JS オブジェクトを呼び出せる Hook です。これにより props のバケツリレー (Props Drilling)を避けられる利点があります。

なお、useContext は、実は公式ドキュメントで紹介されている「基本的な Hooks」の1つです。他の基本的な Hooks は useStateuseEffect なので、useContextも基礎的なものとされていることがわかります。

useContextの使い方

family tree

useContextの使い方を見てみましょう。下記の例は3階層ですが、このようにコンポーネントの階層が深い場合に活用します。

変数familyTreeを Context Object とし、この変数に格納されている子供と孫の名前、年齢を子コンポーネント、孫コンポーネントでそれぞれ表示します。

const familyTree = {
  child: { name: 'Smith', age: 28 },
  grandchild: { name: 'Alice', age: 1 },
} as const

const FamilyTreeContext = React.createContext<typeof familyTree>(null)

const Parent: React.VFC = () => (
  <FamilyTreeContext.Provider value={familyTree}>
    <Child />
  </FamilyTreeContext.Provider>
)

const Child: React.VFC = () => {
  const { child } = useContext(FamilyTreeContext)

  return (
    <main>
      <div>
        <p>name: {child.name}</p>
        <p>age: {child.age}</p>
      </div>

      <GrandChild />
    </main>
  )
}

const GrandChild: React.VFC = () => {
  const { grandchild } = useContext(FamilyTreeContext)

  return (
    <div>
      <p>name: {grandchild.name}</p>
      <p>age: {grandchild.age}</p>
    </div>
  )
}

なお、Context Object は JS のオブジェクトなので、もちろん値以外に関数も渡せます。

useContextを使う前にコンポーネントやContextにまとめる対象を見直す

(本節は追記です)

Prop Drilling を避けたいという理由だけのuseContextの乱用を戒める意見もあります(「props のバケツリレーって何が悪いんだっけ」)。記事内の以下の文の通りだと思います。

Context は横断的な関心事(めっちゃいろんなコンポーネントで使うとか)をメインの動機にすべきで、「階層が深いから」は本質的ではないと思う。

また、記事の最後に React コアチームの @dan_abramov 氏のツイートが引用されています。

「Facebook のルートには約30の Context があるが問題と思っていない。ショートカットキー、ルーティング、データ、モーダル層、フォーカス管理など、異なる範囲をカバーしているから」とのことです。

Context を使う数に上限はないですが、それぞれの Context に渡す値は「文脈」という意味の通り適切に分割することが重要です。

useReducerだけを使うケース

reducer

useReducer は、useStateの代わりとなる Hook です。

複数の値にまたがるロジックがある場合や、前の state に基づいて次の state を決めるタイムトラベルのような機能を実現するために使われます。

useReducer は公式ドキュメントで「追加の Hooks」の1つとされています。ただ、useContextよりuseReducerの方が単体で使われている印象があります。

useReducerの使い方

useReducerの使い方を見てみましょう。以下ではカウンターを作成し、incrementdecrementresetという action を設定しています。

type State = { count: number }

const initialState = { count: 0 }

type Action = 
  | { type: 'increment' } 
  | { type: 'decrement' }
  | { type: 'reset' }

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'reset':
      return initialState
    default:
      return state
  }
}

const Counter: React.FC = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'increment' })}>reset</button>
    </>
  )
}

useReducerに関連するactionをCustom Hooksにまとめる

Custom Hook を作成することで、dispatchを利用した action をあらかじめ作成しておく方法もあります。

// reducer 等は省略

const useCounter = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  const increment = () => dispatch({ type: 'increment' })
  const decrement = () => dispatch({ type: 'decrement' })
  const reset = () => dispatch({ type: 'reset' })

  return { state, increment, decrement, reset } as const
}

const Counter: React.VFC = () => {
  const { state, increment, decrement, reset } = useCounter()

  return (
    <>
      Count: {state.count}
      <button onClick={increment}>-</button>
      <button onClick={decrement}>+</button>
      <button onClick={reset}>reset</button>
    </>
  )
}

useCounterを呼び出せばどのコンポーネントからでもカウンターの機能を呼び出せます。

また、@sonatard 氏の記事で配列を使った stack の実装方法をuseStateからuseReducerにリファクタリングをする例が紹介されています。 記事内でuseStateではなくuseReducerを利用する利点が簡潔に語られています。

配列やオブジェクトの一部を操作する場合のように前回の状態に依存した更新処理をする場合には useState の代わりに useReducer を利用することで、より簡潔に記述することができるようになります。

React Hooksとカスタムフックが実現する世界 - ロジックの分離と再利用性の向上(@sonatard)

リファクタリングの思考過程が丁寧に記述されており、とても参考になります。

useContextとuseReducerの両方を使うケース

useContextuseReducerの両方を使うケースは、部分的に使う場合とアプリケーション全体で使う場合があります。

部分的なコンポーネントツリーで使う

ツリーの一部の葉が赤色になっている

少し状態管理がややこしくなった場合、特定のコンポーネントとその子以下のコンポーネントでuseContextuseReducerを組み合わせて使うのは一般的でしょう。

私は本業で弁護士ドットコムライブラリー というサービスを開発しているので、そこでの使用例を紹介します。

これは法律書籍の読み放題サービスであるため、トップページに書籍の検索欄を設置しています。また詳細な条件を指定する場合は、各条件を入力するフォームをモーダル上に表示します。

この検索欄 Inputコンポーネントとモーダル + 検索条件 Conditionコンポーネントで検索の詳細な条件指定に対応しています。

これらを合わせてSearchInputコンポーネントとし、検索条件の状態管理にuseContextuseReducerを組み合わせて使っています。

弁護士ドットコムライブラリーのトップページ

詳細な検索条件では、キーワードを変更できるのはもちろんのこと、単行本もしくは雑誌から検索したり、出版年や出版社を指定できます。

モーダルと検索条件

条件を集約したいため、下記のState型を一部に持つコンテキストオブジェクトを作成します。なお、具体的な値を省略したりコメントを追加するなどコードは一部改変をしています。

context.tsx
import {AliasName, AliasLabel, PublisherName, TargetName, TargetLabel} from './types'

type SearchConditionConfig = {
  // 省略
}

// コンテキストオブジェクトの state の型
type State = {
  aliases: SearchConditionConfig['aliases']
  target: SearchConditionConfig['targets']['name']
  releaseYear: SearchConditionConfig['releaseYears'][number]
  publishers: SearchConditionConfig['publishers']
}

// type Action, initialState は省略

const initialState = {
  // 省略
}

const reducer = (state, action) => {
  // 省略
}

export const SearchConditionContext = createContext<State | Action>(initialState)

SearchConditionContext.displayName = 'SearchCondition'

// Context Object の Provider(提供側)コンポーネントを返す
const SearchConditionProvider: React.FC = (props) => {
  const [state, dispatch] = useReducer(reducer, initialState)

  // 出版社のチェックボックスをつけたり外したりする
  const togglePublisherByName = (name: PublisherName) => dispatch({ type: 'TOGGLE_PUBLISHER', name })
  // 全ての出版社を選択する
  const selectAllPublisher = () => dispatch({ type: 'SELECT_ALL_PUBLISHER' })
  // 全ての出版社のチェックを外す
  const clearAllPublisher = () => dispatch({ type: 'CLEAR_ALL_PUBLISHER' })

  const value = useMemo(
    () => ({
      state,
      togglePublisherByName,
      selectAllPublisher,
      clearAllPublisher,
    }),
    [state]
  )

  return <SearchConditionContext.Provider value={value} {...props} />
}

// Context Object を子、孫コンポーネントから呼び出すための Custom Hook
export const useSearchCondition = () => {
  const context = useContext(SearchConditionContext)

  if (typeof context === 'undefined') {
    throw new Error('useSearchCondition must be within a SearchConditionProvider')
  }

  return context
}

// 検索条件の state と更新のための action を提供するためのコンポーネント
export const ManagedSearchConditionContext: React.FC = (props) => (
  <SearchConditionProvider>{props.children}</SearchConditionProvider>
)

ManagedSearchConditionContextの書き方は Next.js Commerce の UIContext を参考にしています。

SearchInputコンポーネントをManagedSearchConditionContextでラップし、Context Object(state と action)を子、孫コンポーネントから呼び出せるようにします。

SearchInput.tsx
export type ContainerProps = {
  // 省略
}

type Props = {
  onSubmit: (e: React.SubmitEvent<HTMLFormElement>) => void
  keyword: Keyword
  isOpen: boolean
  // 省略
}

const Component: React.FC<Props> = (props) => (
  <form onSubmit={props.onSubmit}>
    <Input keyword={props.keyword} />

    <Modal open={props.isOpen}>
      <Condition keyword={props.keyword} />
    </Modal>
  </form>
)

const Container: React.FC<ContainerProps> = (props) => {
  // 省略
  
  return (
    <Component
      onSubmit={handleSubmit}
      keyword={nextKeyword}
      isOpen={isOpen}
      {/* 省略  */}
    />
  )
}

const WithContext: React.FC<ContainerProps> = (props) => (
  <ManagedSearchConditionContext>
    <Container {...props} />
  </ManagedSearchConditionContext>
)

WithContext.displayName = 'SearchInput'

export default WithContext

上記のように記述することで、Conditionコンポーネントの中でuseSearchConditionを呼び出せます。

なお、このように部分的なコンポーネントツリーでuseContextuseReducerを組み合わせて使うと、Storybook でSearchInputコンポーネントを表示する際SearchInput単体で表示ができます。

つまり、Storybook のコンポーネントに検索条件コンテキストのProviderを渡すデコレータを用意する必要がなくなるのです。

index.stories.tsx
import { Meta, Story } from '@storybook/react'

import SearchInput, { ContainerProps as Props } from './SearchInput'

export default {
  title: 'features/search/SearchInput',
} as Meta<Props>

const Template: Story<Props> = ({ ...args }) => <SearchInput {...args} />

export const Default = Template.bind({})
Default.args = { ... }

アプリケーション全体で使う

ツリーの全体が赤色になっている

アプリケーション全体で使える場合もあります。ただし、レンダリングパフォーマンスの向上のために状態の参照・更新を分けるといった最適化をした方が良いとされています。

  • React Context を用いた簡易 Store
    • useContext+useState で参照系・更新系を作成する例。Redux の代わりとなる簡易 Store になると記述されている
  • React.Context で作る GlobalUI Custom Hooks
    • useContextuseReducerで「通知」というグローバルなコンポーネントのメッセージと表示制御をする例。参照・更新を分けている
  • Next.js Commerce
    • Next.js を開発している Vercel 社の EC サンプルアプリ(「部分的な利用」で紹介)。useContextuseReducer でサイドバーの開閉などグローバルな UI の状態管理をしている。参照・更新は分けていない

いずれも Redux は使わないことがモチベーションにありそうです。

いろいろ考えたくない人はReduxの方が手軽かもしれない

React Hooks が登場して間もない2018年末頃、useContext + useReducer は Redux の代替になるのか盛んに議論されました。 しかし、現在の状況に照らし合わせると Redux には豊かなエコシステム が存在することが強みであるため、完全には置き換えられていません。

Redux を採用している現場は少なくなく、日本語のドキュメントも豊富であるため、初めて React を触るなど色々考えたくない人はuseContext+useReducerを使うより素直に Redux を導入した方が手軽かもしれません。

実際、@kazuma1989 氏の「ぼくのかんがえたさいきょうの useState + useContext よりも Redux のほうが大抵勝っている」 という記事で、Redux の優れている点が解説されています。

最初から最適化を考える前に(早すぎる最適化) Redux に慣れてきた後に、reselectで返り値をメモ化したり、reducer 内でimmerといった便利なライブラリを導入してもいいかもしれません。

ただ、個人的には Redux は好きですが、Redux を導入するとその豊富なエコシステムゆえに様々な意思決定をしなければならないのも事実です。このため Redux 避けたい気持ちも理解できます。

具体的には、非同期処理を useEffect で行うか、またはredux-thunkに任せるのか。immer は入れるのか、あるいは入れないのか。これらを自分で準備するなら、いっそのこと Redux Toolkit を入れるのかなど…。

関連記事: Redux Toolkitの構成技術を触ってみた(reselect・Immer・Redux Thunk)

React公式はuseContextを最適化したHook「useContextSelector」を実装しようとしている

なぜuseContextとレンダリングパフォーマンス向上の話は切っても切り離せないのでしょうか。それはuseContext + useReducerで状態管理をするとき、dispatchで action を発行して状態を更新すれば、新しい state が生成されてuseContextの返り値が毎回変化するからです。

これは、reducer の役割が action と古い state を受け取り、新しい state を作成することであると考えるとごく自然な結果です。

しかし、reducer の性質により不要な再レンダリングが実行されるのは React にとって問題です。この問題を解決するために、useContextSelectorという Hook な API として実験的に実装されています([Experiment] Context Selectors #20646)。

useContextSelectorはまだ安定的な Hooks ではないため、同じ目的を達成するためには @dai_shi 氏の use-context-selectorQiita)というライブラリがその代替になります。これらの Hooks はどちらも RFC #119 を元に実装されています。

まとめ

他に、サーバーからのレスポンスを格納するだけなら SWR や React Query といった別の手段もあります。

関連記事: useSWRはAPIからデータ取得をする快適なReact Hooksだと伝えたい

技術選択の意思決定に一定の答えはありません。チームのメンバーのスキルやアプリケーションの性質や規模の大小などに応じて、ケースバイケースで決めていくことが重要です。

React設計
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Technology Radarとは

Technology Radarは、ThoughtWorks社が発表している技術トレンドのレポート。2021年3月時点の最新版はv23。

マーティン・ファウラーなど22人の技術者が、実際にThoughtWorks社のプロジェクトで導入したり、試してみたりした結果を発表している。

Technology Radar の PDF もぜひご覧ください。

Technology Radarの読み方

テクノロジーレーダー

Radarは4つのパートに分かれており、さらに4段階のグレードに分かれている。

パート: テクニック、プラットフォーム、ツール、プログラミング言語とフレームワーク

グレードは以下の通り。

  • Adopt(推奨。適切なプロジェクトで導入している)
  • Trial(リスクコントロールした上で導入するのが良い。引き続き注目する価値あり)
  • Assess(事業に与える影響に対する理解を深めながら、試しても良い)
  • Hold(注意しながら進め。実質、「今から使うのはやめておいた方が無難」)

Themes for this edition(今回の主要テーマ)

メインのトレンドが書かれている。

  • GraphQLの良さが誇張されすぎている(抽象化に注意)
  • ブラウザとの戦いはまだ続く
  • 全てを可視化すること(Data visualization)
  • Infrastructure as Code は過渡期である
  • プログラミングの民主化(No Code / Low Code)

「プログラミング言語とフレームワーク」から、フロントエンドのトレンドを掴む

テクノロジーレーダー

Assessがたくさんある。まだまだ成熟していない。

ReduxがAdoptからTrialに変更

前回 Adopt だった Redux が Trial に変更された。

レポート曰く、AdoptからTrialに変更なった主な理由は以下の通り。

  • Reduxはやはりボイラープレートが増えてコードが冗長になって追いにくい。Redux Sagaはそれに拍車をかけている
  • Reactの状態管理はRedux一択だったけど、今はReact本体に状態管理の効率的な機能が備わっているため
  • ただし、状態管理が複雑になれば、やっぱり Reduxか、Recoil(FacebookのRect開発チームが作った状態管理ライブラリ)を使うのが良さそう

Assessに新しいライブラリが登場

以下は、Technology Radar の評を超訳したものです。

Recoil

Recoil は Facebook製の状態管理ライブラリ。Reactらしい書き方ができるので、React開発者に馴染みやすい。グラフ構造で状態を管理する(Redux は木構造)。

使い方がシンプルで学びやすく、ポテンシャルに期待している。

SWR

SWR は ReactでデータフェッチをするHooksのライブラリ。Next.jsを作っているVercel社のOSS。HTTP Cacheのstale-while-revalidateという戦略を適用したライブラリ。

HTTPのキャッシュヘッダーと組み合わせると、キャッシュが更新されずバグの原因になることがあるから気をつけて採用するのが良い。

(前にブログ記事を書いたので、SWRに興味ある方はぜひ)

Mock Service Worker

MSW は APIのテストやモックサーバーの代わりに利用するもの。このライブラリでService Workerのモックがブラウザからサーバーへのリクエストをinterceptして、モックしたService Workerが代わりにレスポンスを返す。REST、GraphQLに対応。

import { setupWorker, rest } from 'msw'

interface LoginBody {
  username: string
}

interface LoginResponse {
  username: string
  firstName: string
}

const worker = setupWorker(
  rest.post<LoginBody, LoginResponse>('/login', (req, res, ctx) => {
    const { username } = req.body
    return res(
      ctx.json({
        username,
        firstName: 'John'
      })
    )
  }),
)

worker.start()

エンドポイントとレスポンスを書くだけでok。

JS の Open Source Award 2019 で賞を受賞したり、React Testing Library の公式サンプルで利用が推奨されている。

Jest と組み合わせて使うと fetch をモックせずに済み、Storybook と一緒に publish するとコンポーネントが実際にリクエストを送った結果が表示される。

(さらに気になる方は、zenn の MSW の導入記事がおすすめです → 「MSW で加速するフロントエンド開発」

XState

XState は興味があれば是非。Finite State Machineで状態管理するアプローチ。visualizationが面白い。

state machine

感想

IaC のツールがたくさん紹介されていたのが印象的だった。フロントエンドの技術ばかりではなく、マイクロフロントエンド(そもそもマーティン・ファウラーが提唱した)などソフトウェアアーキテクチャにも言及しており、視野が広がる。

CTO やテックリード、技術コンサルの方がトレンドを概観するために役に立つ資料だと思いました。

Technology Radar
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Elm触ってみた

以下は2019年2月に社内勉強会で発表した内容です。本文の記述は最新の情報ではない場合があります。

最新の情報はElm の公式ドキュメントをご覧ください。

発表すること

  • Elm触ったらFluxが理解できたよ

発表しないこと

  • 関数型言語の一般的な説明
    • 「第一級の関数(first class function)」
    • 高階関数
    • カリー化
    • 作用・副作用の話
    • などなど

Elmという言語

特徴

  • 静的型付けの関数型言語(typed functional language)
  • コンパイルしてJSを吐き出す
  • SPAが作れる
  • The Elm Architecture
  • バージョンは0.19

An Introduction to Elm

「Elmで何か作ってみると、Elmの考え方が身につくのでJSやReactがうまく書けるようになるよ」

If you are on the fence, I can safely guarantee that if you give Elm a shot and actually make a project in it, you will end up writing better JavaScript and React code. The ideas transfer pretty easily!

https://guide.elm-lang.org/

Reduxに影響を与えてる。

チュートリアル

カウンターを作る

button.elm
import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)


main =
  Browser.sandbox { init = init, update = update, view = view }


-- MODEL

type alias Model = Int

init : Model
init =
  0


-- UPDATE

type Msg = Increment | Decrement

update : Msg -> Model -> Model
update msg model =
  case msg of
    Increment ->
      model + 1

    Decrement ->
      model - 1


-- VIEW

view : Model -> Html Msg
view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

https://guide.elm-lang.org/architecture/buttons.html

Elmの構造

The Elm Architectureに基づいてアプリケーションを作る。 MVCではない。

The Elm Architectureでは下記の関数を使用する

  • Model
  • Update
  • View

Model

  • アプリケーションの本体(と言ってもいいと思う)
  • 型で定義する
  • カウンターの場合、ただのint型。
  • initで初期化する。ここではModelを数値の0と定義している。
-- MODEL

type alias Model = Int

init : Model
init = 0

(これは下記と同義)
init : Int
init = 0

update関数

  • Msg -> Model -> Model
  • メッセージと前のモデルを受け取って、新しいモデルを返す
  • モデルの更新(操作)を担う
  • Modelの変更はここに集約されている
-- UPDATE

-- メッセージを定義
type Msg = Increment | Decrement

update : Msg -> Model -> Model
update msg model =
  -- メッセージの内容に応じて、モデルを操作する
  case msg of
    Increment ->
      model + 1

    Decrement ->
      model - 1

view関数

  • Model -> Html Msg
  • モデルを受け取って、Htmlを返す
  • ただの表示を担う箇所と、Msgが埋め込まれている箇所がある
-- VIEW

view : Model -> Html Msg
view model =
  div []
    -- ボタンがクリックされた時onClick関数が発火し、update関数にMsgが送られる
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

ElmとRedux

  • ElmはReduxの設計思想に影響を与えている
  • ReduxはFlux。Elmは The Elm Architecture
  • ただ、データの流れは一方向という点で同じ

FluxとThe Elm Architectureの比較

Flux

FluxElm共通点
ActionMsgイベント
DispatcherUpdate状態の更新
StoreModel状態の管理
ViewView表示

You Might Not Need Redux(Dan Abramov)

以下はReactでReduxなしでFluxを表現したコード

counter.js
import React, { Component } from 'react';

const counter = (state = { value: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { value: state.value + 1 };
    case 'DECREMENT':
      return { value: state.value - 1 };
    default:
      return state;
  }
}

class Counter extends Component {
  state = counter(undefined, {});
  
  dispatch(action) {
    this.setState(prevState => counter(prevState, action));
  }

  increment = () => {
    this.dispatch({ type: 'INCREMENT' });
  };

  decrement = () => {
    this.dispatch({ type: 'DECREMENT' });
  };
  
  render() {
    return (
      <div>
        <button onClick={this.increment}>+</button>
        {this.state.value}
        <button onClick={this.decrement}>-</button>
      </div>
    )
  }
}

https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367

Elmと似てる

イベント

  increment = () => {
    this.dispatch({ type: 'INCREMENT' });
  };

  decrement = () => {
    this.dispatch({ type: 'DECREMENT' });
  };

Msg

type Msg = Increment | Decrement

状態の更新

現在の状態を受け取る。

dispatcher

const counter = (state = { value: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { value: state.value + 1 };
    case 'DECREMENT':
      return { value: state.value - 1 };
    default:
      return state;
  }
}

 dispatch(action) {
   this.setState(prevState => counter(prevState, action));
 }

update

update : Msg -> Model -> Model
update msg model =
  case msg of
    Increment ->
      model + 1

    Decrement ->
      model - 1

状態の管理

state = counter(undefined, {});

const counter = (state = { value: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { value: state.value + 1 };
    case 'DECREMENT':
      return { value: state.value - 1 };
    default:
      return state;
  }
}
type alias Model = Int

init : Model
init = 0

表示

view

render() {
  return (
    <div>
      <button onClick={this.increment}>+</button>
      {this.state.value}
      <button onClick={this.decrement}>-</button>
    </div>
  )
}
view : Model -> Html Msg
view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

(注)Elmはコンポーネント指向ではない

  • Reactはコンポーネントの組み合わせ
  • Elmは1つのアプリケーション

(参考)「Elm のコンポーネント論争とは何か」 http://jinjor-labo.hatenablog.com/entry/2017/05/12/183154

結論

  • Elmを学ぶことでFluxが理解できる!
  • Reactもうまく書けそう!
  • 型でプログラミングする、という感覚がつかめた!

Elm関連サイト

Elm - A delightful language for reliable webapps.

An Introduction to Elm(チュートリアル)

Elm 2日ほどやった感想(mizchi’s blog)

余談

Elmでポートフォリオサイト作ってみました

Elm設計
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

Custom DocumentでNext.jsで各ページのHTMLタグを上書きする

Next.js には Custom Document という機能があります。これは、各ページで共通の HTML を上書きするための機能です。

html タグ、body タグに属性を付与したり、SEO 対策をするために meta タグを記述することに使われます。

今回は、OGP の公式ドキュメントに記載されている prefix 属性と値(prefix="og: http://ogp.me/ns#")を html タグに付与する方法を紹介します。

Next.jsでhtmlタグにprefixを付与する

pagesディレクトリに _document.tsx を作成します。以下のように記述すると、全てのページの html タグに prefix 属性が付与されます。

_document.tsx
import Document, { Head, Html, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html prefix="og: http://ogp.me/ns#">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

404ページはprefixを付与しないための設定をする

Next.js では pagesディレクトリに 404.tsx を作成すると、リソースが存在しないときに自動で 404 ページが表示されます。

この 404 ページなど、特定のページでは html タグから prefix 属性を削除したいケースがあると思います。

その時は、getInitialProps内でパスの判定をして場合分けをしましょう。

_document.tsx
import Document, { Html, Head, Main, NextScript } from 'next/document'

const htmlPrefix = 'og: http://ogp.me/ns#'

class MyDocument extends Document<{ prefix: string | null }> {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx)
    // 404 ページには prefix を設定しない
    const prefix = ctx.pathname.startsWith('/404') ? null : htmlPrefix

    return { ...initialProps, prefix }
  }

  render() {
    const prefix = this.props.prefix

    return (
      <Html prefix={prefix}>
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

getInitialPropsの return 前にconsole.log(ctx.pathname, prefix)を差し込み、$ npm run build を実行してみます。すると、以下のようなログが出力されます。

$ npm run build
> next-starter-kit@1.0.0 build /sample_program/react/nextjs-playground
> next build

info  - Creating an optimized production build
info  - Compiled successfully
info  - Collecting page data
/ og: http://ogp.me/ns#
/404
/recoil og: http://ogp.me/ns#
/redux og: http://ogp.me/ns#
info  - Generating static pages (32/32)
info  - Finalizing page optimization

Page                                                           Size     First Load JS
┌ ○ /                                                          2.9 kB         81.9 kB
├   /_app                                                      0 B              79 kB
├ ○ /404                                                       279 B          79.3 kB
├ ○ /recoil                                                    15.5 kB        94.6 kB
├ ○ /redux                                                     626 B          79.7 kB
+ First Load JS shared by all                                  79 kB
  ├ chunks/1e4bfcbb482c0ad992d70a6f3b42612b47faafd5.172fb8.js  5.19 kB
  ├ chunks/e2988e983c224d75e4d495ad6342b845f7d73227.4953e9.js  13.8 kB
  ├ chunks/framework.4d50c5.js                                 42.1 kB
  ├ chunks/main.5f9b00.js                                      6.77 kB
  ├ chunks/pages/_app.858252.js                                10.4 kB
  ├ chunks/webpack.50bee0.js                                   751 B
  └ css/fda66c1c8420c26251ed.css                               3.16 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)

ログを見ると、/404 では空文字であることがわかりますね。

next export で出力を確認する

$ next build && next exportを実行し、 HTML を出力して結果を確認してみましょう。

index.html404.htmlのを比較します。

out/index.html
<!DOCTYPE html>
<html prefix="og: http://ogp.me/ns#">
<head>
<!--  ...  -->
out/404.html
<!DOCTYPE html>
<html>
<head>
<!--  ...  -->

これで prefix 属性の出し分けができました!

ページ遷移時にhtmlの変更に対処する

なお、上記のままでは 404 ページから通常のページに遷移したとき、prefix 属性が存在しないままになります。反対に、通常ページから 404 ページにリンクしている場合、404 ページでも prefix 属性が付与されたままになります。

これは、そもそもクライアントサイドルーティングでは Next.js が ページごとに JS で表示するコンテンツを書き換えているのですが、その際 html タグまでは書き換えないからです。

OGP は特定の Web ページを SNS 等でリッチに表示するための手段なので、ページ遷移時の挙動まで考慮しなくても良いかもしれません。

ただ、もしページ遷移時も prefix を出し分けしたいのであれば、以下のような Custom Hooks を作って対応可能です。

htmlにprefix属性を付与するCustom Hooks

prefix 属性を付与したり削除するためには、タグの属性を操作するsetAttributeremoveAttributehasAttributeという API を活用します。

まず、prefix 属性を付与するuseAddHtmlPrefix を作成します。

useAddHtmlPrefix.ts
import { useRouter } from 'next/router'
import { useEffect } from 'react'

const prefix = 'prefix'
const htmlPrefix = 'og: http://ogp.me/ns#'

export default function useAddHtmlPrefix() {
  const { pathname } = useRouter()
  const is404Page = pathname.startsWith('/404')

  useEffect(() => {
    if (is404Page) {
      return
    }

    const hasHtmlPrefix = document.documentElement.hasAttribute(prefix)

    // prefix が存在しない場合、 404 以外のページに prefix を設定する
    if (!hasHtmlPrefix) {
      document.documentElement.setAttribute(prefix, htmlPrefix)
    }
  }, [is404Page])
}

この Custom Hooks を_app.tsxで呼び出します。

pages/_app.tsx
import { AppProps } from 'next/app'
import useAddHtmlPrefix from '~/hooks/useAddHtmlPrefix'

const App = ({ Component, pageProps }: AppProps) => {
  useAddHtmlPrefix()

  return <Component {...pageProps} />
}

export default App

htmlのprefix属性を削除するCustom Hooks

useRemoveHtmlPrefix.ts
import { useEffect } from 'react'

const prefix = 'prefix'

export default function useRemoveHtmlPrefix() {
  useEffect(() => {
    const hasHtmlPrefix = document.documentElement.hasAttribute(prefix)

    if (hasHtmlPrefix) {
      document.documentElement.removeAttribute(prefix)
    }
  }, [])
}

この Custom Hooks を404.tsxで呼び出すと、html から prefix 属性を削除できます。

pages/404.tsx
import useRemoveHtmlPrefix from '~/hooks/useRemoveHtmlPrefix'

export default function Error() {
  useRemoveHtmlPrefix()

  return <div>404</div>
}

以上、Next.js で html の prefix 属性の値prefix="og: http://ogp.me/ns#"をページごとに出し分ける方法でした。

参考

Add lang attribute to html for better accessibility #9160

Next.js
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer

TL;DR

  • Electron で Markdown のエディタを作った
  • Next.js + Tailwind CSS といった Web 技術で作れた
  • 完璧ではないものの十分使えるものになった

プレビューモード

自分用のエディタなので、「Panda Editor」という名前にしました。

本記事の内容

社内勉強会で「Electron + Next.js + Tailwind CSS でエディタを作った」という題で発表をしました。作ったエディタで解決したかった課題を伝えて実際に動くところ見てもらうデモをしたところ、嬉しいことに好評だったので記事として残すことにしました。

Electron とは HTML、CSS、JS でデスクトップアプリを作れる GitHub 製 OSS です。クロスプラットフォームビルドができる(Windows・Mac・Linux)ところに特徴があり、VSCode や Slack、Figma のアプリも Electron 製です。

本記事では Electron、Next.js、Tailwind CSS の詳細には触れません。今回は各技術の記述は少なめで、作ったアプリケーションの説明がメインです。詳しくはそれぞれの公式ドキュメントをご覧ください。

ちなみに、この記事自体も「Panda Editor」で執筆しています。

ブログ記事の執筆はエディタだけで完結しない

私はブログ執筆のためのベストなエディタにまだ出会っていません。これまで PhpStorm で記事を執筆していましたが、何か物足りないと感じていました。

そこで執筆のプロセスを考え直してみました。すると、PhpStorm で足りないと感じていた理由はブログ執筆という作業がエディタで書くことだけで完結しないからだと気づきました。

例えば、自分の知識の正しさを確認するためにブラウザで検索もします。Markdown でリンクを作成するためにリンク先の URL をコピペします。参照したい内容が PDF にあるときはローカルで該当の PDF を探して開きます。

つまり、記事を執筆する作業には知識の確認や引用というプロセスがあるためエディタだけでは完結しないのです。

書くモードと読むモード

知識の確認、引用プロセスがあることを考えるとエディタ以外のアプリケーションを立ち上げなければなりません。しかし、画面を切り替えると同時に、書くモードから読むモードに頭を切り替えなければなりません。

書くモードと読むモードの比率を考えると以下のようなところでしょうか(書くモード:読むモード)。

  • 書くことは、調べることと同時並行(9:1)
  • ウェブサイトや PDF を読みながらメモを取る(2:8)
  • エディタで書く(10:0)
  • ブラウザで記事を読む(0:10)

特にエディタからブラウザ、ブラウザからエディタの移動はモードをフルで切り替えるため意識の負担になります。

このため、書く・読む(調べる)をシームレスに行えるアプリがあると記事執筆が快適にできると考えました。

解決へのアプローチ

前述の課題の解決方法の1つは「調べながら書ける」アプリを作ることです。そこで、デスクトップアプリを Web の技術で作れる Electron を採用しました。Web アプリでも良かったのですが、Electron を使ってみたかったのでそちらにしました。

まず、有名どころのエディタを見直してみました。例えば PhpStorm で Markdown を開いた場合や Qiita や esa のエディタでは、左半分で執筆して右半分で Markdonw をプレビューするという仕様です。

しかし、**右側が必ずしもプレビューである必要はないと考えました。**この考え方を軸にしてエディタの仕様を決めていきました。

エディタの4つのモード

読むことと書くことをシームレスに行うため、エディタに「プレビューモード」「検索モード」「ファイル閲覧モード」「校正モード」という4つのモードを用意しました。

プレビューモード

プレビューモード

プレビューモードは、react-markdown を利用して Markdown で記述した文章を HTML で表示するものです。

{/* preview */}
{props.mode === 'preview' && (
  <section>
    <ReactMarkdown
      className="md-preview mt-8 py-2 px-3 h-176 whitespace-pre-wrap overflow-y-auto border-2 border-gray-300"
      plugins={[gfm]}
      unwrapDisallowed={false}
    >
      {props.body}
    </ReactMarkdown>
  </section>
)}

よくある機能です。なお、CSS は Tailwind CSS で記述しています。

検索モード

検索モード

検索モードは、「書きながら調べる」を実現するための機能です。右上の虫眼鏡のアイコンをクリックすると、Google のトップページを表示します。

iframe では Google のトップページを表示できないため、Electron の BrowserView という機能で実装しています。

ツールバーでページの「進む」「戻る」ができます。また、アクセスしているサイトの URL を表示できるため、URL をコピーして記事に貼り付けることでリンク作成が簡単にできます。

ファイル閲覧モード

ファイル閲覧モード

ファイル閲覧モードは、「読みながら書く」を実現するための機能です。ローカルに保存した PDF ファイルを読みながらメモを取ることを想定しています。

Electron は chromium ベースなので、Google Chrome で PDF を表示した場合と同じ機能が使えます。

PDF 以外にも.md, .txt の拡張子を持つファイルを表示できます。

ローカルファイルを表示する仕組み

自分用のエディタの話をするだけでは味気ないため、裏側のことも少し紹介します。

「ローカルファイルを開く」ボタンを押すとフロントでfileView-mode-open-fileという独自に定義したイベントが発火します。

Electron 側でdialog.showOpenDialogを実行し、ダイアログを表示します。選択したファイルのパスが取得できるため、ファイルの中身をアプリ上で閲覧できます。

ダイアログを表示

例えば PDF ファイルを選択すると、以下の画像のように表示できます。

ファイル閲覧モード

コードは以下の通りです。

React

<button
  type="button"
  onClick={() => global.ipcRenderer.send('fileView-mode-open-file')}
>
  ローカルファイルを開く
</button>

Electron

ipcMain.on('fileView-mode-open-file', async (_: IpcMainEvent) => {
  // ダイアログを開く
  // pdf, md, txt しか選択できないようにしている
  const { filePaths } = await dialog.showOpenDialog({
    filters: [{ name: 'Research', extensions: ['pdf', 'md', 'txt'] }],
    properties: ['openFile'],
  })

  if (filePaths.length === 0) {
    return
  }

  const filename = filePaths[0]
  const view = mainWindow.getBrowserView()

  // BrowserView で選択肢たファイルを表示する
  view.webContents.loadURL(`file://${filename}`)
})

React は renderer プロセス、Electron は main プロセスを刺しますが、説明は省略します。

校正モード

校正モード

@azu さんの textlint で文章のチェックをします。このモードではリントのルールでエラーになった行とエラー内容を表示します。

今までは記事を書き上げた後に textlint を適用し、エラーに従って文章が冗長だったりおかしいところを修正していました。

しかし、1つの記事を書き上げた後、最後にリントを適用すると修正するべき箇所が多いため面倒でした。それならリアルタイムでリントをかければいいと考えました。

また、lint の fix 機能を使っているため、「修正する」ボタンをクリックすると自動修正できるエラーであれば一発で修正可能です。

「Panda Editor」では日本語向けのルールの preset を使っています。ただ、技術ブログ執筆に最適な技術記事用のルールもあるので、こちらも導入予定です。

textlint でエラーが出る文章例

例えば、以下のような文はエラーと判定されます。

私は、リントに引っかかる文章を書くことができる

textlint-rule-no-mix-dearu-desumasuというルールのエラーです。エラーメッセージは152行目: 本文: "である"調 と "ですます"調 が混在 => "ですます"調 の文体に、次の "である"調 の箇所があります: "である。" Total: である : 1 ですます: 34です。

この後は雨は降る

textlint-rule-no-doubled-joshiというルールのエラーです。エラーメッセージは157行目: 一文に二回以上利用されている助詞 "は" がみつかりました。です。

1つ目の文は「私は、リントに引っかかる文章を書けます」、2つ目は「この後は雨が降る」のように修正するとエラーが消えます。

なお、簡便のためエラーが出たままでもファイルを保存できるようにしています。

リント実行の実装の一部

Electron の main プロセスで以下のように textlint を実行しています。

textlint-executeは文字を入力するたびに、textlint-fix-executeは「修正する」ボタンを押すと実行されます。

// 文字を入力するたびにリントを実行
ipcMain.handle('textlint-execute', (_: IpcMainInvokeEvent, markdown: string) => {
  return lintEngine.executeOnText(markdown, '.md').then((results) => results[0].messages)
})

// 「修正する」ボタンを押すと、エラーを修正する
ipcMain.handle('textlint-fix-execute', (_: IpcMainInvokeEvent, markdown: string) => {
  return lintFixEngine.executeOnText(markdown, '.md').then((results) => results[0].output)
})

このリントの実行結果をフロントで受け取り、画面右半分に表示しています。

エディタの機能

左半分のエディタ自体の機能は以下のようなものです。

  • 行数を表示
  • ローカルにファイルを保存
    • ~/tmpディレクトリにdraft.mdという名前で保存される
  • Cmd + S で保存、2分ごとに自動保存
  • 「コピーする」ボタンで文章の全文をクリップボードにコピーする

今はシンプルなものなので、「タブでインデントする」「エディタとプレビュー画面のスクロールを同期」などの便利な機能は未実装です。改良の余地はまだまだあります。

開発者から見た Electron

「Electron を使うと Web の技術でデスクトップアプリが作れる」とは、renderer を Web の技術スタック(HTML + CSS + JS)で実装可能という意味でした。

このため、フロントに Next.js をあえて選ぶ必要はありません。Vanilla JS でも Vue.js でも OK です。なお、Next.js の getServerSideProps や API Routes といったサーバーを利用した機能は使えません。

その他、Electron を触ってみて学んだことを列挙します。

  • Electron は、Chromium + Node.js + Custom API(OSのネイティブ関数を扱う)で構成されている
  • main プロセスと renderer プロセスがある
  • main プロセスがアプリを立ち上げ、renderer プロセスが Web サイトとして表示する
  • main プロセスと renderer プロセスはプロセス間通信をする
    • 「プロセス間通信は、ipcMain と ipcRenderer の IPC (Inter-Process Communication) モジュールを介して行うことができます」
  • 最適化は骨が折れそう(パフォーマンス戦略
    • 「Panda Editor」は368MB。もっと多機能な Slack は 194MB

詳しくはDocmentを参照。

Electron に興味を持たれた方は、ぜひ公式のデモアプリ(Electron API Demo)をインストールして動かしてみてください。

まとめ

スピード重視で作ったので、2週間ちょっとで作れました。そろそろリファクタリングの時期です。

プログラマの良いところは自分でソフトウェアを作れることですね。ソフトウェアを実際に使い、不満があったらコードを書き換え、ソフトウェアの挙動を変える。それは、課題解決のために道具を自分で作り替えることに他なりません。

私が今まで作った個人開発のアプリケーションの中では、誰かにしっかり使ってもらえそうだという手応えがあります。ただ、機能が万全ではないので公開したり配布するつもりは今のところありません。

もう少し作り込んでサブスクリプションとして販売すると売上は立ちそうな気がしますが、商用にすることも特に考えていないです。

また個人開発で面白いものが作れたら記事に残していきます。

参考

faao (textlint 作者の @azu さんの OSS。実装を参考にしました。設計がとても綺麗)

ElectronNext.jsTailwind CSS
プログラミングをするパンダ
プログラミングをするパンダ (@Panda_Program)
Software Engineer