Algolia InstantSearchのカスタムUIをReact+typescript+Material-UIで作成する



#はじめに
全文検索を可能にするalgoliaには、フロントエンド向けに[InstantSearch](https://www.algolia.com/doc/api-reference/widgets/instantsearch/react/)という非常に便利なパッケージを提供してくれています。InstantSearchでは、`SearchBox`や`Hits`といった、検索に必要なUIを提供してくれているため、特にカスタマイズすることなく利用することは可能です。しかし、独自のデザインテーマの中でalgoliaの検索を使いたい場合、検索ボックスや検索結果リストをカスタマイズしたい場合あります。そのため、今回はあえて提供されているUIを使わずに、Material-UIのベースにInstantSearchの`connector`という[HOC](https://ja.reactjs.org/docs/higher-order-components.html)を使ってオリジナルの検索UIを作成します。

#作成するUI
* 検索ボックス(SearchBox)
* 検索結果表示(Hits)

![React App.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200228/410e4544-0d7d-f5b3-7f31-8dc89e208a56.gif)

#使用するパッケージ
* algoliasearch
* material-ui/core
* react-instantsearch-dom

#前提知識
* ract→[Higher-Order-Components(HOC)](https://ja.reactjs.org/docs/higher-order-components.html)を使います
* typescript
* algolia
* material-ui

#方針
InstantSearchではカスタムUIを作成する方法を複数提供してくれていますが、今回は[connector](https://www.algolia.com/doc/guides/building-search-ui/widgets/customize-an-existing-widget/react/#connectors)というHOCを利用します。このHOCを利用することで、作成するコンポーネントに対して、algoliaのクエリ情報や検索結果をpropsとして得ることができます。
#手順
##Rootコンポーネント
Rootとなるコンポーネントでは`InstantSearch`の内側に各UIのコンポーネントを配置します。これはカスタムの有無関わらず必要な手順となります。

```tsx:index.tsx
import React from 'react';
import algoliasearch from 'algoliasearch';
import { InstantSearch } from 'react-instantsearch-dom';
import SearchBox from './SearchBox'; //後述
import Hits from './Hits';//後述

const searchClient = algoliasearch('algoliaのapp-id','algoliaのapi-key');

const Search: React.FC = () => {
    return (<InstantSearch searchClient={searchClient} indexName='index名'>
        <SearchBox />{/* 検索ボックス */}
        <Hits />{/* 検索結果 */}
    </InstantSearch>)
}

export default Search;
```
※ `InstantSearch`は最新のクエリを`state`として内包するコンポーネントに伝搬するため、必ず`InstantSearch`の内側に各コンポーネントを置く必要があります。

##検索ボックス
`connectSearchBox`というHOCを使います。これによって検索クエリの更新を行う`refine`と最新の検索文字列を保持する`currentRefinement `をpropsとして得ることができます。

```tsx:SearchBox.tsx
import React from 'react';
import { connectSearchBox } from 'react-instantsearch-dom';
import { SearchBoxProvided } from 'react-instantsearch-core';
import InputBase from '@material-ui/core/InputBase';
import SearchIcon from '@material-ui/icons/Search';
import { makeStyles } from '@material-ui/core/styles';

const useStyle = makeStyles({
    root: {
        backgroundColor: '#6578FF',
        padding : '0.5rem 3rem'
    },
    inputWrapper: {
        display : 'flex',
        alignItems : 'center',
        backgroundColor: '#92A1FF',
        borderRadius: '5px',
        padding: '0.5rem 1rem',
        maxWidth : '40vw',
    },
    icon: {
        opacity : '0.6',
        marginRight : '0.5rem',
    }
})

const SearchBox: React.FC<SearchBoxProvided> = ({
    refine,
    currentRefinement,
}) => {
    const classes = useStyle();
    return (
        <div className={classes.root}>
            <div className={classes.inputWrapper}>
                <SearchIcon className={classes.icon}/>
                <InputBase placeholder='Search messages' value={currentRefinement} onChange={(e) => {
                    refine(e.target.value);
                }} />
            </div>
        </div>
    )
};

export default connectSearchBox(SearchBox);
```
このように実装することで、検索ボックスの文字列を更新するたびに検索結果が更新されます。

##検索結果
ここでは、検索結果をリストで表示する`Hits`と、`Hits`内で使用する`Highlight`コンポーネントを作成します。
###Hits
`connectHits`というHOCを使うことで、検索結果の配列(`hits`）をpropsとして得ることができます。

```tsx:Hits.tsx
import React from 'react';
import { HitsProvided, Hit } from 'react-instantsearch-core';
import { connectHits } from 'react-instantsearch-dom';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import { makeStyles } from '@material-ui/core/styles';
import { Message } from './types';
import Highlight from './Highlight'; // 後述

const useStyle = makeStyles({
    root: {
        overflowY: 'scroll',
        maxHeight : '90vh'
    },
    listItem: {
        borderBottom : '1px solid rgba(0,0,0,0.1)'
    }
})

const Hits: React.FC<HitsProvided<Hit<Message>>> = ({
    hits,
}) => {
    const classes = useStyle();
    return (
        <List className={classes.root}>
            {hits.map(hit => (
                <ListItem key={hit.objectID} button className={classes.listItem}>
                    <ListItemText secondary={<Highlight hit={hit} attribute='senderName'/>}>
                        <Highlight hit={hit} attribute='message'/>
                    </ListItemText>
                </ListItem>
            ))}
        </List>
    )
};

export default connectHits(Hits);
```
`hits`の型は`HitsProvided<Hit<アイテムの型>>`となります。`Hit<アイテムの型>`には、アイテムの型に加えて、検索にヒットした際の情報が保存されています。これはハイライトなどに必須なので、typescriptを使う場合は忘れないようにしてください。

###Highlight
`connectHighlight`というHOCを使うことで、`highlight`関数をpropsとして得ることができます。この関数は`hit`オブジェクトを引数に取り、マッチした（ハイライトする）部分とそうでない部分に結果を分解してくれます。また、`attribute`にはハイライトの対象としたいデータのフィールド名を指定します。

```tsx:Highlight.tsx
import React from 'react';
import { HighlightProps } from 'react-instantsearch-core';
import { connectHighlight } from 'react-instantsearch-dom';
import { Message } from './types';

const HighlightHit: React.FC<HighlightProps<Message>> = ({
    hit,
    highlight,
    attribute
}) => {
    const parsedHit = highlight({
        highlightProperty: '_highlightResult',
        attribute,
        hit,
    });
    return (
        <React.Fragment>
            {parsedHit.map(
                (part, index) =>
                    part.isHighlighted ? (
                        <mark key={index}>{part.value}</mark>
                    ) : (
                            <span key={index}>{part.value}</span>
                        )
            )}
        </React.Fragment>
    )
};

export default connectHighlight(HighlightHit);
```

###(参考) InfiniteHits
検索結果を一部ずつロードしたい場合、`connectHits`の代わりに`connectInfiniteHits`というHOCを使うことができます。この場合propsは`InfiniteHitsProvided<Hit<アイテム>>`となります。

```tsx:InfiniteHit.tsx
import React from 'react';
import { InfiniteHitsProvided, Hit } from 'react-instantsearch-core';
import { connectInfiniteHits } from 'react-instantsearch-dom';

・・・略

const Hits: React.FC<InfiniteHitsProvided<Hit<Message>>> = ({
    hits,
    hasMore, // まだ読み込んでいないデータが存在する場合true
    refineNext // 次のデータ群を読み込む
}) => {
    const classes = useStyle();
    return (
        <List className={classes.root}>
            {hits.map(hit => (
                <ListItem key={hit.objectID} button className={classes.listItem}>
                    <ListItemText secondary={<Highlight hit={hit} attribute='senderName'/>}>
                        <Highlight hit={hit} attribute='message'/>
                    </ListItemText>
                </ListItem>
            ))}
            {hasMore && <Button className={classes.button} onClick={refineNext}>...More</Button>}
        </List>
    )
};

export default connectInfiniteHits(Hits);
```

#カスタム可能なUI
他にも利用できる`connector`は[公式ページ](https://www.algolia.com/doc/api-reference/widgets/instantsearch/react/)もしくは、[公式のGithub](https://github.com/algolia/react-instantsearch/tree/master/packages/react-instantsearch-core/src/connectors)にて確認することができます。また、オリジナルの`connector`を作成することも可能で、その手順も公開されています。→[Create Your Own Widgets](https://www.algolia.com/doc/guides/building-search-ui/widgets/create-your-own-widgets/react/)
#最後に
最後まで見ていただいありがとうございます。今回作成したコードは[Github](https://github.com/mktu/algolia-instantsearch-sample/tree/master/instantsearch-sample)に置いています。