【脱Redux?】Open SOQLを状態ストアとしてReactでアプリを作ってみる

# はじめに

先日、「[SQLライクな「グラフ」クエリエンジンOpen SOQLを作ってみた](https://qiita.com/shellyln/items/5ca107f5afe3fea831ed)」という記事で、クエリ言語SOQLのオープンソース実装「[Open SOQL](https://github.com/shellyln/open-soql)」をご紹介しました。

<img alt="open-soql" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185253/7d23a133-8248-ca7c-a26d-7836243c7c1b.png" width="350px">


今回は、Reactのアプリケーション状態ストア（つまり、Redux等の代替）としてOpen SOQLを使ってみたいと思います。
~~（注：現時点では更新の購読機能（Pub / Sub）がOpen SOQLにはありませんので、まだ実用レベルではありません）~~
【2020-08-28追記】`v0.3.0` にて Publish/Subscribeメッセージ送信に対応しました:tada:

# インストール

```bash
npx create-react-app my-open-soql-app --template typescript
cd my-open-soql-app
npm install open-soql
```

# コードの変更
## カスタムフックを定義する

```tsx:soql-hooks.ts
import { useState } from 'react';
import { IQuery, QueryParams } from 'open-soql/modules/types';

export function useSoql<R>(query: IQuery, params?: QueryParams) {
    const [loading, setLoading] = useState(null as (boolean | null));
    const [err, setErr] = useState(null as (any | null));
    const [data, setData] = useState(null as (R[] | null));
    
    const refetch = () => {
        query.execute<R>(params)
            .then(d => {
                setLoading(false);
                setErr(null);
                setData(d);
            })
            .catch(e => {
                setLoading(false);
                setErr(e);
                setData(null);
            });
        setLoading(true);
        setErr(null);
        setData(null);
    };

    if (loading === null) {
        refetch();
    }

    return { loading, err, data, refetch };
}
```

このカスタムフックのインターフェースは[ApolloClientのReact API](https://www.apollographql.com/docs/react/api/react/hooks/)を参考にしました。

## リゾルバを定義する
リゾルバによってストアを構築します。

```tsx:types.ts
export interface Account_norels {
    Id: string;
    Name: string | null;
    Address: string | null;
}

export interface Account extends Account_norels {
    Contacts: Contact[] | null;
}

export interface Contact_norels {
    Id: string;
    Foo: string | null;
    AccountId: string | null;
}

export interface Contact extends Contact_norels {
    Account: Account;
}
```

```tsx:commands.ts
import { build } from 'open-soql/modules/builder';
import { Account_norels, Contact_norels } from './types';

type Store = {
    Account: Account_norels[];
    Contact: Contact_norels[];
};

const store: Store = {
    Account: [
        { Id: 'Account/z1', Name: 'bbb/z1', Address: 'ccc/z1' },
        { Id: 'Account/z2', Name: 'bbb/z2', Address: 'ccc/z2' },
        { Id: 'Account/z3', Name: 'bbb/z3', Address: 'ccc/z3' },
        { Id: 'Account/z4', Name: null    , Address: null     },
        { Id: 'Account/z5', Name: ''      , Address: ''       },
    ],
    Contact: [
        { Id: 'Contact/z1', Foo: 'aaa/z1', AccountId: 'Account/z1' },
        { Id: 'Contact/z2', Foo: 'aaa/z2', AccountId: 'Account/z1' },
        { Id: 'Contact/z3', Foo: 'aaa/z3', AccountId: 'Account/z2' },
        { Id: 'Contact/z4', Foo: null    , AccountId: null         },
        { Id: 'Contact/z5', Foo: ''      , AccountId: null         },
    ],
};

export const { compile, soql, insert, update, remove, touch, transaction, subscribe, unsubscribe } = build({
    relationships: { // オブジェクト間のリレーションシップを定義します
        Account: { Contacts: ['Contact'] },
        Contact: { Account: 'Account' },
    },
    resolvers: {
        query: { // 選択クエリのリゾルバ
            Account: (fields, conditions, limit, offset, ctx) => {
                let data = store.Account;
                if (ctx.parent && ctx.parentType === 'detail') {
                    data = data.filter(x => x.Id === (ctx.parent as any)[ctx.foreignIdField!]);
                }
                return Promise.resolve(data.map(x => ({...x})));
            },
            Contact: (fields, conditions, limit, offset, ctx) => {
                let data = store.Contact;
                if (ctx.parent && ctx.parentType === 'master') {
                    data = data.filter(x => x.AccountId === (ctx.parent as any)[ctx.masterIdField!]);
                }
                return Promise.resolve(data.map(x => ({...x})));
            },
        },
        update: { // 更新DMLのリゾルバ
            Account: (records: Partial<Account_norels>[], ctx) => {
                const ret: Partial<Account_norels>[] = [];
                for (const rec of records) {
                    const index = store.Account.findIndex(x => x.Id === rec.Id);
                    if (index < 0) {
                        throw new Error('Record is not exists!');
                    }

                    const original = store.Account[index];
                    const changed = { ...original, ...rec };

                    store.Account[index] = changed;
                    ret.push(changed)
                }
                return Promise.resolve(ret);
            },
            Contact: (records: Partial<Contact_norels>[], ctx) => {
                const ret: Partial<Contact_norels>[] = [];
                for (const rec of records) {
                    const index = store.Contact.findIndex(x => x.Id === rec.Id);
                    if (index < 0) {
                        throw new Error('Record is not exists!');
                    }

                    const original = store.Contact[index];
                    const changed = { ...original, ...rec };

                    store.Contact[index] = changed;
                    ret.push(changed)
                }
                return Promise.resolve(ret);
            },
        },
        // 他に insert, remove のリゾルバを定義できます
    },
});
```

## フックを使うコンポーネントを作る

```tsx:Accounts.tsx
import   React,
       { useEffect }   from 'react';
import { Subscriber }  from 'open-soql/modules/types';
import { Account,
         Contact }     from './types';
import { compile,
         update,
         subscribe,
         unsubscribe } from './commands';
import { useSoql }     from './soql-hooks';
import                      './Accounts.css';

const query = compile`
    Select
        Id
      , Name
      , Address
      , (Select Id, Foo from Contacts where Foo > '')
    from
        Account
    where
        Name > :condName`;

function Accounts() {
    const { loading, err, data, refetch } = useSoql<Partial<Account>>(query, { condName: '' });

    const subscriber: Subscriber = ({resolver, on, id}) => {
        refetch();
    };

    // 副作用フックでDMLイベントを購読します
    // 登録解除時は、登録時と同じ関数オブジェクトを渡す必要があります
    useEffect(() => {
        const d = data;
        if (d) {
            for (const acc of d) {
                subscribe('Account', acc.Id, subscriber);
                for (const con of acc.Contacts ?? []) {
                    subscribe('Contact', con.Id, subscriber);
                }
            }
        }

        return () => {
            if (d) {
                for (const acc of d) {
                    unsubscribe('Account', acc.Id, subscriber);
                    for (const con of acc.Contacts ?? []) {
                        unsubscribe('Contact', con.Id, subscriber);
                    }
                }
            }
        }
    });

    if (loading || !data) {
        return <div>Loading...</div>;
    }
    if (err) {
        return <div>Error!</div>;
    }

    const handleAccountClick = async (rec: Partial<Account>) => {
        await update('Account', {...rec, Name: `${rec.Name}+`});
    };

    const handleContactClick = async (rec: Partial<Contact>) => {
        await update('Contact', {...rec, Foo: `${rec.Foo}+`});
    };

    return (
        <div className="Accounts-wrap">
            <ul>{data.map(x =>
                <li key={x.Id} style={{cursor: 'pointer'}}>
                    <h3 onClick={() => handleAccountClick(x)}>{x.Name} - {x.Address}</h3>
                    <ul>{(x.Contacts?? []).map(c =>
                        <li key={c.Id}>
                            <div onClick={() => handleContactClick(c)}>{c.Foo}</div>
                        </li>
                    )}
                    </ul>
                </li>
            )}
            </ul>
        </div>
    );
}

export default Accounts;
```

## コンポーネントをページに追加する

```tsx:App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.css';
import Accounts from './Accounts'; // ← 追記

function App() {
  return (
    <div className="App">
      <header className="App-header">
        ...                                 { /* 既存コード */ }
        <div style={{ display: "flex" }}>   { /* ← 追記 */ }
          <div style={{ border: "solid 1px", margin: 2, paddingRight: 20 }}>
            <Accounts />
          </div>
          <div style={{ border: "solid 1px", margin: 2, paddingRight: 20 }}>
            <Accounts />
          </div>
        </div>
      </header>
    </div>
  );
}

export default App;
```

コード全体は https://github.com/shellyln/open-soql-react-hooks-example-app にて見られます。


# 実行

```bash
npm start
```

<img alt="execution-result" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185253/db11c988-c18e-07ea-c109-d69f90e892dd.png" width="550px">
イベント購読により、左右のコンポーネントの表示が同期されます。

# さいごに

[Open SOQL](https://github.com/shellyln/open-soql)のクライアント・ローカルなリゾルバをアプリケーション状態ストアとしてReactを使うことができました。

~~前述の通り、現時点では[Open SOQL](https://github.com/shellyln/open-soql)に Pub/Sub によるイベント機能が無いため、離れた階層にあるコンポーネントからのストア更新で自動的に描画更新させることはできませんが、Open SOQLの次期バージョンではDML実行によるサブスクライバへの通知を実現していきたいと考えています。~~
【2020-08-28追記】`v0.3.0` にて Publish/Subscribeメッセージ送信に対応しました。