blog.ryota-ka.me

@cycle/react を触ってみる

数日前,@staltz 氏から Cycle React のリリースがアナウンスされた.

https://twitter.com/andrestaltz/status/1019546640582152192

@cycle/react を含む一連のライブラリ群は,React component を Cycle.js の中で用いることを可能にし,Cycle.js の component を React の中で用いることを可能にする.

モチベーション#

氏は Use React in Cycle.js and vice-versa という記事で,次のように述べている.

I have been critical of React, but that’s a normal thing as a framework author: you have different opinions on how frontend should be done. I’ve always been very positive about React Native, though, because it’s a game changer. It allows us to bring the JavaScript ecosystem to mobile development, without compromising (that much) on user experience.

@hiroqn に聞いた話だが,Cycle.js で React Native を書くというアイディアは数年前から既にあったらしく,かつての README を読んでみると,CycleConf 2016 のハッカソンで開発されたものが原型らしい.

氏は Cycle React の存在には以下のような意義があるとしている.

  • React ユーザにとっては
    • 開発に Cycle.js のアーキテクチャを取り入れることができる
      • cycle-onionify のような state management ツールが利用可能になる
      • 様々な driver を使って副作用を管理できるようになる
    • @cycle/react によって
      • コンポーネントを
        • this-less に書ける
        • class を使わずに書ける
        • コード量が少なくなる
        • const だけで (immutable に)書ける
      • stream を使った reactive なプログラミングができる
  • Cycle.js ユーザにとっては
    • React コミュニティに存在する数多の資産が利用可能になる
    • Cycle.js で記述されたコンポーネントを React ユーザに対しても提供できる
    • React Native を Cycle.js で記述できる

と,React ユーザにとってはあまり嬉しい訳ではないような気がする*1が,Cycle.js の側からすると,React コミュニティの資産にアクセスできるようになるのは魅力的である.

使い方#

通常の Cycle.js アプリケーションとは異なり,@cycle/dom の代わりに @cycle/react を用いる.加えて,プラットフォームに合わせて @cycle/react-dom@cycle/react-native のどちらかを選ぶ.

上記記事中のサンプルコードを見ればわかるが,基本的には JSX は用いずに,@cycle/react が export している h 関数を用いることを想定している.

React は handleClick といった書き方で,イベントが発生した際に関数を proactive に呼び出す一方,Cycle.js は reactive なので,DOM 側はどういったイベントを引き起こしうるのかは知らず,component の中で sources.DOM.select('.selector').events('click') といった形でイベントの stream を取得するという方式を取っている.しかしながら,CSS セレクタという概念が存在しない React Native にも対応するためには,別の仕組みを用意するしかないので @cycle/dom とは少し事情が異なり,higher-order component で囲むことによってこれを実現するというアプローチを取ったようだ.sel を渡すために JSX は使えないので,hyperscript を前提としたインタフェースを採用したらしい.実際記事中にも,sel が利用できないことを除けば JSX でも動作するし,将来的に JSX の完全なサポートがなされるかもしれないが,あくまで hyperscript の方が優先,という趣旨の記述がある.現時点で JSX だけで済ませたい場合には,以下のような SFC を用意しておくとなんとかなるが,あまり推奨されるようなものではないだろう.

import * as React from 'react';

import { incorporate } from '@cycle/react/lib/cjs/incorporate';

export type Props = {
  sel: string | symbol;
  children: React.ReactNode;
};

function Incorporator<P = any>(props: Props): React.ReactElement<P> {
  const { children, sel } = props;

  return React.createElement<any>(incorporate('div'), { sel }, children);
}

export default Incorporator;
const handleClick = Symbol();

function View(...) {
    return (
        <Incorporator sel={ handleClick }>
            <Button />
        </Incorporator>
    );
}

function main({ react }) {
    const clickEvent$ = react.select(handleClick).events('click');
    // ...
}

コードサンプル#

ここからは実際に @cycle/react を用いたアプリケーションを作ってみる.冒頭で述べたとおり,@cycle/react を使えば

  • React component を Cycle.js 中で利用できる
  • Cycle.js の component を React 中で利用できる

という2つの恩恵を得ることができるのだが,今回は前者のみを検証する Cycle.js アプリケーションを作成する.また,@cycle/react-dom@cycle/react-native という選択肢があるが,今回は @cycle/react-dom を用いる.加えて,せっかく React のエコシステムに乗っかるので,何か React らしいものをと考えた結果,styled-components を取り入れることにした.

以下のパッケージをインストールする.

$ yarn add @cycle/react @cycle/react-dom @cycle/run cycle-restart react react-dom styled-components xstream
$ yarn add -D @types/react @types/react-dom html-webpack-plugin ts-loader typescript webpack webpack-serve

コード(雑)はこんな感じ.

// src/components/Greeter.tsx

import * as React from 'react';

import styled from 'styled-components';

export type Props = {
  name: string;
};

const H1 = styled.h1`
  color: green;
  margin-bottom: 20px;
`;

export default function Greeter({ name }: Props): JSX.Element {
  return <H1>Hello, {name}!</H1>;
}
// src/components/NameInput.tsx

import * as React from 'react';

import styled from 'styled-components';

export type Props = {
  name: string;
};

const Input = styled.input`
  border-radius: 4px;
  background-color: #fafafa;
  color: black;
  height: 16px;
  line-height: 16px;
`;

export default function NameInput({ name }: Props): JSX.Element {
  return <Input type="text" value={name} />;
}
// src/components/Main.tsx

import * as React from 'react';

import { ReactSource } from '@cycle/react';
import { MemoryStream, Stream } from 'xstream';

import Greeter from '../Greeter';
import NameInput from '../NameInput';
import Incorporator from '../Incorporator';

export type SoReact = { react: ReactSource };
export type SiReact = { react: Stream<React.ReactElement<any>> };

export type Sources = { react: ReactSource };
export type Sinks = { react: Stream<React.ReactElement<any>> };

function Main({ react }: Sources): Sinks {
  const name$: MemoryStream<string> = react
    .select('event-input-name')
    .events('change')
    .map((e: any) => (e.persist(), e.target.value)) // ここで飛んでくる event は React の SyntheticEvent なので,module.hot があるときだけ persist したいがめんどくさい……
    .startWith('');

  const vdom$: Stream<React.ReactElement<any>> = name$.map((name) => (
    <div>
      <Greeter name={name} />
      <Incorporator sel="event-input-name">
        <NameInput name={name} />
      </Incorporator>
    </div>
  ));

  return {
    react: vdom$,
  };
}

export default Main;
// src/index.ts

import { setup } from '@cycle/run';
import { makeDOMDriver } from '@cycle/react-dom';

const { rerunner, restartable } = require('cycle-restart');

import Main from './components/Main';

function makeDrivers() {
  return {
    react: restartable(makeDOMDriver(document.getElementById('app')), { pauseSinksWhileReplaying: false }),
  };
}

const rerun = rerunner(setup, makeDrivers);
rerun(Main);

if ((module as any).hot) {
  (module as any).hot.accept('./components/Main', () => {
    const { default: newMain } = require('./components/Main');

    rerun(newMain);
  });
}

cycle-restart で HMR もできる.試しにやってみるとこんな感じ.

HMR

Web を書くときに Snabbdom の代わりに React を強く使いたい場面があるかというとちょっとわからないけれども,React Native が書けるというのは面白そう.

コード全文はこちらから.

脚注#

*1: 既に React コミュニティに広く受け入れられているプラクティスも多々あるように見受けられる.