しばしばユーザーがなにか複雑なルールを構築するシステムをwebアプリケーション上に実現する必要があります。例えばアンケートシステムの設問の表示・非表示の切り替え条件、顧客がどのような顧客なのか自動判別するためのチェックリストの各項目のチェック条件といったものが挙げられます。そんなときに便利なのが React Query Builder です。React Query Builder はユーザーが直感的に条件を追加・削除しながらクエリを構築できるコンポーネントを提供してくれます。本記事では、React Query Builder を使って実際に条件式を構築する方法を紹介します。
React Query Builder
react-querybuilder/react-querybuilder: The Query Builder component for React
インストール方法はよくある npm パッケージです。次コマンドは npm ですが他パッケージマネージャでも同様にインストールできます。
npm install react-querybuilder
React Query Builder は動的なクエリ構築UIを提供するライブラリ で主に以下の機能があります。
- GUIで条件式を構築可能
- 条件式の追加・削除ができる
- 複数の条件式をAND、OR、()でまとめられる
- JSON形式でクエリを出力できる
実際に react-querybuilder を使ったデモの一例が次です。これのバージョンは react-querybuilder v8.2.0, react 19 です。ルールとそれをつなぐAND・ORとルールをまとめる括弧相当のグループを使って自由に条件式を作れます。
ソースコード
// QueryBuilderDemo.tsx import { useState } from "react"; import { QueryBuilder, RuleGroupType } from "react-querybuilder"; import "react-querybuilder/dist/query-builder.css"; import { motion } from "framer-motion"; const fields = [ { name: "name", label: "名前" }, { name: "type", label: "種類" }, { name: "status", label: "ステータス" }, ]; const QueryBuilderDemo = () => { const [query, setQuery] = useState<RuleGroupType>({ "combinator": "and", "rules": [ { "field": "status", "operator": "!=", "valueSource": "value", "value": "BANNED" }, { "combinator": "and", "not": false, "rules": [ { "field": "name", "operator": "beginsWith", "valueSource": "value", "value": "浜松" }, { "field": "name", "operator": "endsWith", "valueSource": "value", "value": "太郎" } ], } ], }); return ( <div style={{ padding: "20px", maxWidth: "600px", margin: "auto", fontFamily: "Arial, sans-serif" }}> <h1 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "10px" }}>React Query Builderデモ</h1> <div style={{ border: "1px solid #ccc", borderRadius: "8px", padding: "10px", backgroundColor: "#f9f9f9" }}> <QueryBuilder fields={fields} query={query} onQueryChange={setQuery} /> </div> <motion.div style={{ marginTop: "10px", padding: "10px", backgroundColor: "#eaeaea", borderRadius: "6px" }} initial={{ opacity: 0 }} animate={{ opacity: 1 }}> <h2 style={{ fontSize: "16px", fontWeight: "bold", marginBottom: "5px" }}>JSON形式のクエリデータ</h2> <pre style={{ backgroundColor: "#fff", padding: "10px", borderRadius: "6px", border: "1px solid #ccc", fontSize: "14px" }}> {JSON.stringify(query, null, 2)} </pre> </motion.div> </div> ); }; export default QueryBuilderDemo; // main.tsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import QueryBuilderDemo from "./QueryBuilderDemo.tsx"; createRoot(document.getElementById('root')!).render( <StrictMode> <QueryBuilderDemo /> </StrictMode>, )
基本的に React Query Builder のコンポーネントを呼び出し、値とクエリを渡せばそれで動きます。React Query Builder で扱う型はまあまあ大きいのでTypeScriptを使った方が楽です。
React Query Builder は素の時点でデザインを持っていますが、これは既存のシステムの見た目になじまないことがそれなりにあり、CSSで対処するにも限界があります。これを補うために React Query Builder が作るクエリを表現する各コンポーネントの定義を外から渡せるようになっています。これを使うと次のようにデザインを大きく変えることもできます。
ソースコード
// QueryBuilderCustomElementDemo.tsx // ここは前のコードとほとんど同じです。違うのはrqbMaterialUiControlElementsを呼び出してQueryBuilderに渡している点です。 import { useState } from "react"; import { QueryBuilder, RuleGroupType } from "react-querybuilder"; import "react-querybuilder/dist/query-builder.css"; import { motion } from "framer-motion"; import {rqbMaterialUiControlElements} from "./RqbMaterialUiControls.tsx"; const fields = [ { name: "name", label: "名前" }, { name: "type", label: "種類" }, { name: "status", label: "ステータス" }, ]; const QueryBuilderCustomElementDemo = () => { const [query, setQuery] = useState<RuleGroupType>({ combinator: "and", rules: [ { field: "status", operator: "!=", valueSource: "value", value: "BANNED" }, { combinator: "and", not: false, rules: [ { field: "name", operator: "beginsWith", valueSource: "value", value: "浜松" }, { field: "name", operator: "endsWith", valueSource: "value", value: "太郎" } ], } ], }); return ( <div style={{ padding: "20px", maxWidth: "700px", margin: "auto", fontFamily: "Arial, sans-serif" }}> <h1 style={{ fontSize: "22px", fontWeight: "bold", marginBottom: "12px", color: "#333" }}>React Query Builder デモ(カスタムデザイン)</h1> <div style={{ border: "3px solid #444", borderRadius: "10px", padding: "12px", backgroundColor: "#fafafa" }}> <QueryBuilder fields={fields} query={query} onQueryChange={setQuery} controlElements={rqbMaterialUiControlElements} /> </div> <motion.div style={{ marginTop: "12px", padding: "12px", backgroundColor: "#e0e0e0", borderRadius: "8px" }} initial={{ opacity: 0 }} animate={{ opacity: 1 }}> <h2 style={{ fontSize: "18px", fontWeight: "bold", marginBottom: "6px", color: "#222" }}>JSON形式のクエリデータ</h2> <pre style={{ backgroundColor: "#fff", padding: "12px", borderRadius: "8px", border: "2px solid #bbb", fontSize: "14px" }}> {JSON.stringify(query, null, 2)} </pre> </motion.div> </div> ); }; export default QueryBuilderCustomElementDemo; // RqbMaterialUiControls.tsx // ReactQueryBuilderで使うコンポーネントの定義です。ここではMaterialUIでデザインしてます。 import {Button, IconButton, MenuItem, Select, TextField} from "@mui/material"; import {Delete} from "@mui/icons-material"; import { ActionWithRulesAndAddersProps, CombinatorSelectorProps, FieldSelectorProps, OperatorSelectorProps, ValueEditorProps, ControlElementsProp, FullField } from "react-querybuilder"; // フィールド選択 const FieldSelector = ({options, value, handleOnChange}: FieldSelectorProps) => ( <Select value={value} onChange={(e) => handleOnChange(e.target.value)} size="small" style={{backgroundColor: "#fff"}}> {options.map((field) => ( <MenuItem key={field.name} value={field.name}>{field.label}</MenuItem> ))} </Select> ); const operatorLabelMap: {[p:string]:string} = { "=": "=", "!=": "!=", "<": "<", "<=": "<=", ">": ">", ">=": ">=", "begins with": "前方一致", "ends with": "後方一致", "contains": "文字列に含む", "does not contain": "文字列に含まない", "does not begin with": "前方一致しない", "does not end with": "後方一致しない", "is null": "NULL", "is not null": "NULL でない", "in": ",区切りの要素に含まれる", "not in": ",区切りの要素に含まれない", "between": "範囲内", "not between": "範囲外" }; // オペレーター選択 const OperatorSelector = ({options, value, handleOnChange}: OperatorSelectorProps) => ( <Select value={value} onChange={(e) => handleOnChange(e.target.value)} size="small" style={{backgroundColor: "#fff"}}> {options.map((operator) => 'name' in operator && typeof operator.name === 'string' && 'label' in operator ? ( // OptionGroup の場合を除外.グループを使いたい時は別途実装 <MenuItem key={operator.name} value={operator.name}> {operatorLabelMap[operator.label] ?? operator.label} </MenuItem> ) : null )} </Select> ); // 値入力エディター const ValueEditor = ({operator, value, handleOnChange}: ValueEditorProps) => { if (operator === "null" || operator === "notNull") { return null; } if (operator === "between" || operator === "notBetween") { return ( <div style={{display: "flex", gap: "8px"}}> <TextField value={value?.[0] || ""} onChange={(e) => handleOnChange([e.target.value, value?.[1] || ""])} size="small" style={{backgroundColor: "#fff"}} placeholder="開始" /> <TextField value={value?.[1] || ""} onChange={(e) => handleOnChange([value?.[0] || "", e.target.value])} size="small" style={{backgroundColor: "#fff"}} placeholder="終了" /> </div> ); } return ( <TextField value={value} onChange={(e) => handleOnChange(e.target.value)} size="small" style={{backgroundColor: "#fff"}}/> ) }; // コンビネーター選択 const CombinatorSelector = ({options, value, handleOnChange}: CombinatorSelectorProps) => ( <Select value={value} onChange={(e) => handleOnChange(e.target.value)} size="small" style={{backgroundColor: "#fff"}}> {options.map((option: { name: string; label: string }) => ( <MenuItem key={option.name} value={option.name}>{option.label}</MenuItem> ))} </Select> ); // ルール追加ボタン const AddRuleAction = ({handleOnClick}: ActionWithRulesAndAddersProps) => ( <Button variant="contained" color="secondary" onClick={handleOnClick} size="small" style={{backgroundColor: "#fff", color: "#222"}}> ルール追加 </Button> ); // グループ追加ボタン const AddGroupAction = ({handleOnClick}: ActionWithRulesAndAddersProps) => ( <Button variant="contained" color="secondary" onClick={handleOnClick} size="small" style={{backgroundColor: "#fff", color: "#222"}}> グループ追加 </Button> ); // ルール削除ボタン const RemoveRuleAction = ({handleOnClick}: ActionWithRulesAndAddersProps) => ( <IconButton onClick={handleOnClick} size="small" color="error" style={{backgroundColor: "#fff"}}> <Delete/> </IconButton> ); // グループ削除ボタン const RemoveGroupAction = ({handleOnClick}: ActionWithRulesAndAddersProps) => ( <IconButton onClick={handleOnClick} size="small" color="error" style={{backgroundColor: "#fff"}}> <Delete/> </IconButton> ); // まとめてエクスポート export const rqbMaterialUiControlElements: ControlElementsProp = { fieldSelector: FieldSelector, operatorSelector: OperatorSelector, valueEditor: ValueEditor, combinatorSelector: CombinatorSelector, addRuleAction: AddRuleAction, addGroupAction: AddGroupAction, removeRuleAction: RemoveRuleAction, removeGroupAction: RemoveGroupAction, }; // main.tsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import QueryBuilderDemo from "./QueryBuilderCustomElementDemo.tsx"; createRoot(document.getElementById('root')!).render( <StrictMode> <QueryBuilderDemo /> </StrictMode>, )
このデモでは選択欄、入力欄、ボタンをMaterialUIというデザインライブラリのコンポーネントを使ったものに置き換えています。他にも入力欄の複製ボタン等の多くの部分が入れ替えられます。この辺りはTypeScriptの型や公式ドキュメントを読むとわかります。
こんな感じでReact Query Builderを使うといい感じにブラウザ上で複雑なクエリを構築する画面をユーザーに提供できます。クエリが出来上がったら後はそれを元にサーバーに送るなりなんなりして後の工程でよしなにするだけです。こういった機能は自分で1から作ると結構大変なのでReact Query Builderをベースに要件に合わせて適宜活用できると大変便利です。