@react-querybuilder/vue is a port, not a rewrite. It renders the same DOM, accepts (nearly) the same
props, and delegates all query logic to the same @react-querybuilder/core package that React
Query Builder itself uses. This page lists everything that is not the same.
The port's defining constraint is full DOM parity: tag name, document order, data-testid,
data-path, and byte-identical class attributes match React Query Builder's output.
This is not aspirational. The repository downloads a conformance fixture asset published by the
upstream project (pinned to v8.22.2) and asserts, for 49 scenario × query combinations, that the
full ordered list of rendered elements matches byte for byte — plus 49 accessible-description
cases and 58 replayed action sequences. See packages/vue-querybuilder/test/conformance/.
Any undocumented difference in rendered output is a bug. Please report it.
None of the following is planned for v1:
- Drag and drop. There is no
@react-querybuilder/dndequivalent, noDragHandlecomponent, and noDragHandleProps/UseRuleDnD/UseRuleGroupDnDtypes. The root element always rendersdata-dnd="disabled". - UI-framework compatibility packages (Ant Design, Bootstrap, Bulma, Chakra, Fluent, Mantine, MUI, Tremor).
expranddatetimeUI packages. The core-level functionality is available through the re-exported core package; the React components are not ported.- Async option lists (
useAsyncOptionList). - Deprecated props and aliases.
ActionWithRulesPropsand friends, and the deprecated per-prop fallbacksRuleGroupProps.combinator/rules/notandRuleProps.field/operator/value/valueSource, are all absent. UseruleGroupandrule. ruleGroupHeaderElements/ruleGroupBodyElements. The equivalent internal components exist (RuleGroupHeader,RuleGroupBody) but are notcontrolElementskeys.
React Query Builder keeps query state in a Redux store, addresses each builder instance by a
qbId, and threads a dispatchQuery function through Schema.
This port has none of that. State lives in a QueryManager instance from
@react-querybuilder/core. Consequently:
SchemadropsdispatchQueryandqbId, and gainsmanager: QueryManager.- There is no
qbIdregistry and no store-level entry point for external control. - Undo/redo is read directly off
schema.manager, not off a Redux history slice.
To drive a query builder from outside the component tree, construct the manager yourself and pass it in:
<script setup lang="ts">
import { QueryBuilder, QueryManager, formatQuery, type Field } from '@react-querybuilder/vue';
const fields: Field[] = [
{ name: 'firstName', label: 'First Name' },
{ name: 'lastName', label: 'Last Name' },
];
// `history: true` is what enables undo/redo. A manager you construct yourself brings its own
// options, so history there is your opt-in — `QueryBuilder` will not add it for you.
const manager = new QueryManager({ combinator: 'and', rules: [] }, { fields, history: true });
const addRule = () => manager.add({ field: 'firstName', operator: '=', value: '' }, []);
const undo = () => manager.undo();
const log = () => console.log(formatQuery(manager.getQuery(), 'sql'));
</script>
<template>
<QueryBuilder :manager="manager" :fields="fields" />
<button @click="addRule">Add rule from outside</button>
<button @click="undo">Undo</button>
<button @click="log">Log SQL</button>
</template>QueryManager keeps its history in private class fields, which a reactive Proxy cannot read
through. Do not wrap a manager in reactive(); if you must, toRaw() it before calling it.
React exposes query/defaultQuery/onQueryChange. This port keeps all three and adds
v-model:query.
| Binding | Semantics |
|---|---|
:default-query |
Uncontrolled. The component seeds its manager and owns the query. |
v-model:query |
Two-way. Sugar for :query + @update:query. |
:query + :on-query-change |
Controlled, React-style. |
:manager |
The component subscribes to a manager you own. |
update:query is the only emit. Everything else stays a callback prop, for two reasons:
- Veto callbacks must return a value.
onAddRule,onAddGroup,onRemove,onMoveRule,onMoveGroup,onGroupRule, andonGroupGroupcan cancel a pending change by returningfalse, or replace it by returning a new rule/query. A Vue emit is fire-and-forget, so these cannot be emits. - Parity. Keeping the rest as props means React documentation and examples transfer unchanged.
onQueryChange fires first, then update:query, both exactly once per committed change — even
inside a manager.batch(), which produces a single commit.
- The query is held internally in a
shallowRef. Queries are immutable and replaced wholesale, and the manager deep-freezes them via Immer, so a deep reactive proxy would be both wasteful and rejected. - A parent that holds the query in
reactive()or spreads it on every change hands back a proxy of the object just emitted. The controlled-mode watcher guards against the resulting feedback loop with anObject.isfast path followed by amanager.signatureOf()comparison, so this is safe — but a genuine replacement (for example a deep clone) is correctly pushed through.
controlElements works exactly as in React: a partial map of 24 keys, each a component that
replaces the default. Setting a key to null renders nothing for that control.
<QueryBuilder :fields="fields" :control-elements="{ addRuleAction: MyButton, dragHandle: null }" />Configuration inherits through provide/inject rather than React context. Call
provideQueryBuilderContext() from an ancestor to set controlElements, controlClassnames,
translations, and the display flags for every QueryBuilder beneath it; per-instance props win
over inherited values, key by key.
Key-named scoped slots are the Vue-native alternative. For every key x of controlElements
there is a slot #x, whose slot props are exactly the props that control element receives:
<QueryBuilder :fields="fields" v-model:query="query">
<template #addRuleAction="{ label, handleOnClick }">
<button type="button" @click="handleOnClick">{{ label }}</button>
</template>
</QueryBuilder>Slots and controlElements entries are interchangeable everywhere downstream: a slot is adapted
to a component by an internal functional wrapper, cached by slot identity so that a re-render
never remounts the subtree.
Resolution order, applied per key independently:
- Levels, in order: props → inherited context → package defaults.
- Within a level: keyed slot → keyed component → bulk slot → bulk component.
Consequences worth spelling out:
- A slot passed to
QueryBuilderbeats a component inherited from context, and a slot supplied to a context provider beats a component from a further-out provider — but a component passed directly toQueryBuilderbeats an inherited slot, because levels are tried before sources. controlElements: { x: null }short-circuits at its own level, so it renders nothing even when an outer provider supplies an#xslot.- Bulk sources are
actionElement(keys endingAction/Actions) andvalueSelector(keys endingSelector). They never apply tovalueEditor,rule,ruleGroup,inlineCombinator,notToggle, ormatchModeEditor.
Because slots must be inheritable, they also have a prop form: QueryBuilderContextProps.slots,
a Partial<ControlSlots>. QueryBuilder populates it from its own scoped slots, so passing it
by hand is only necessary when forwarding slots through a context provider. An explicit slots
prop wins over a template slot of the same name.
There is no null form for a slot. Omit it to fall through, or use controlElements: { x: null }
to render nothing.
See customization.md for worked examples.
| React Query Builder | This port |
|---|---|
ReactNode (labels) |
LabelNode = VNodeChild | string. Titles stay string. |
ComponentType<P> |
Vue's Component<P> |
React's synthetic MouseEvent |
The DOM MouseEvent |
Schema.dispatchQuery, qbId |
Removed; Schema.manager added |
Controls['undoRedoActions'] nullable |
Non-nullable — the manager always owns history |
Additional deltas:
- All four type parameters of
QueryBuilderPropsare defaulted (RG = RuleGroupType,F = FullField,O = FullOperator,C = FullCombinator), so the type is usable bare. QueryBuilderPropsis a conditional type; its body isQueryBuilderPropsBase. Vue's SFC compiler enumerates prop keys itself and cannot see through a conditional type (Unresolvable type: TSConditionalType), so the component declares the non-conditional base interface. Both are exported. The public conditional type is unchanged in meaning.- Convenience aliases
SimpleQueryBuilderProps,SimpleQueryBuilderPropsIC,SimpleRuleProps, andSimpleRuleGroupPropsname the fully-defaulted forms. QueryBuilderContextPropsomitsenableDragAndDropandpreserveQueryStateOnUnmount.Schemastill carriesenableDragAndDrop, because it feeds the rootdata-dndattribute — which is always"disabled".ValueEditorProps.skipHookkeeps its name but now refers to the value-editor reset watcher rather than a React hook.ControlSlotsis a mapped type overControls: for every keyK, aSlot<ControlProps<Controls[K]>>. The slot list and its argument types therefore cannot drift from the components the slots replace.QueryBuilderContextProps.slotscarries it.RuleTypeOf<RG>recovers the rule type from a query type.QueryBuilderis generic inRG,F,O, andConly — the rule type is determined by the query, not chosen independently — so the component uses this to fillQueryBuilderPropsBase's explicitR.RuleandRuleGroupare generic too (F/O), matching React. The parameters are a consumer-facing convenience; internally the props are widened to the default instantiation, becauseSchema's resolvers are invariant in their option types.- A generic SFC's props parameter carries an index signature.
vue-tsctypes it asProps & Record<string, unknown>, so an interface-typed variable is not directly assignable when the component is invoked throughh(). Spread it, or add the index signature. Templates are unaffected.
React's hooks are not ported. The reactive layer is public API under Vue-idiomatic names:
| Composable | Role |
|---|---|
useQueryBuilder |
Manager resolution, query state, schema, actions, derived config |
useQueryActions |
Adapts manager mutators to QueryActions; applies veto callbacks |
useRule / useRuleGroup |
Everything a Rule/RuleGroup implementation needs |
useRuleContext / useRuleGroupContext |
Path-based resolved configuration for external callers |
useValueEditorReset |
The value-editor reset effect |
provideQueryBuilderContext / useQueryBuilderContext |
Configuration inheritance |
Notes:
- React's
useMemographs are not reproduced. The large memo blocks inRule.tsxandRuleGroup.tsxare a dependency specification, not logic: nearly all of the work is done by core'sderiveRuleContext,deriveRuleGroupContext,deriveRuleClassNames, andderivePathInfo. Vue'scomputedhandles the rest. - The value-editor reset watcher uses an explicit dependency array and
flush: 'post'.flush: 'post'matches React's post-commituseEffect; core supplies what to reset, not when. An explicit dependency array (rather thanwatchEffect) removes the auto-tracking failure mode where the tracked set changes across branches, which is exactly how such an effect loops. immediate: trueis never paired withflush: 'post'. Vue runs an immediate callback synchronously at watch creation, ignoring the flush setting, which would apply a reset before first render and diverge from React's post-paint behavior. The mount-time run is deferred withnextTickinstead.useQueryBuilderContext()returns aComputedRef | undefined— a computed, so inherited configuration stays reactive;undefinedwhen called outside a component instance.
- Structural manager options are applied in place. Changing
fields,operators,combinators,baseField/baseOperator/baseCombinator, the boolean flags,maxLevels,disabledPaths,validator, oridGeneratorafter mount re-applies them to the existing manager throughQueryManager#reconfigure, so the query, the undo/redo history, and every subscriber survive. Function props (getOperators,getValues,getDefaultValue, …) are forwarded through closures and stay live without any reconfiguration at all. An externally suppliedmanagerprop is never reconfigured — that manager belongs to the consumer. The watcher runs atflush: 'post'and is gated by a structural deep compare, so a props object rebuilt on every render does not retrigger it. ValueSelectordrives a multi-select through each<option>'sselectedattribute, not avaluebinding, which Vue would stringify into a cleared selection. Rendered DOM is unchanged.- Every default control sets
inheritAttrs: false.RuleandRuleGrouphand each subcomponent a common prop bag (rule,rules,ruleOrGroup,fieldData, …) that most controls do not declare; without this, Vue would land them on the DOM as stray attributes React never emits. A custom control should do the same. - Every boolean prop is declared with an explicit
undefineddefault. Vue casts an omittedBooleanprop tofalse, which is not the same as "not configured" —autoSelectField,enableMountQueryChange, and theresetOn*flags all default totrue, and a strayfalsewould override an inherited context value. If you write a wrapper component aroundQueryBuilder, do the same. - Vue's Vapor mode is a supported authoring target. The library uses no render functions that
assume the virtual DOM, no
$el, and no manual DOM patching. There is no Vapor CI gate until Vue 3.6 is stable.