| name | relay-infinite-scroll-select |
| description | Create Relay-based infinite scroll select components extending BAISelect. Supports name-based values (usePaginationFragment) and id-based values (useLazyLoadQuery + useLazyPaginatedQuery) with search, optimistic updates, and multiple selection modes.
|
Relay Infinite Scroll Select Component Creator
Activation Triggers
- "Create a Relay infinite scroll select for [entity]"
- "Build a select with GraphQL pagination"
- "Add [Entity]Select component with infinite scroll"
- "Create a select component that fetches from GraphQL"
Quick Start Decision Tree
START: Does your select need dynamic query parameters?
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Q: Do you need dynamic control over query parameters? ā
ā (filter, limit, first, order, etc.) ā
ā OR need external refetch capability? ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā
āāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāā
ā ā
NO YES
ā ā
ā¼ ā¼
āāāāāāāāāāāāāāā āāāāāāāāāāāāāāā
ā Pattern A ā ā Pattern B ā
ā (Simple) ā ā (Dynamic) ā
āāāāāāāāāāāāāāā āāāāāāāāāāāāāāā
ā ā
ā¼ ā¼
Reference: References:
BAIAdminResourceGroupSelect BAIUserSelect (email-based)
BAIVFolderSelect (id-based)
Pattern Comparison
| Criteria | Pattern A (Simple) | Pattern B (Dynamic) |
|---|
| Value Type | String (name) | Any (name, email, id, row_id, etc.) |
| Relay Hook | usePaginationFragment | useLazyLoadQuery + useLazyPaginatedQuery |
| Queries | 1 fragment | 2 queries |
| Dynamic First | ā Not needed | ā
Default (fetches all selected values) |
| Dynamic Parameters | ā Limited | ā
Full control (filter, first, limit, order, etc.) |
| Multiple Mode | Single only | Full support |
| Global ID | Not needed | Can handle (if needed) |
| Optimistic UI | Not needed | Required |
| State Management | Simple | Complex |
| Ref Export | No | Yes (refetch support) |
| Complexity | š¢ ~100 lines | š” ~300-350 lines |
| Use Case | Simple, static requirements | Dynamic filters, external refetch, multiple props control |
Pattern B Examples by Value Type
| Example | Value Type | Special Features |
|---|
| BAIUserSelect | Email | Dynamic first, email filtering |
| BAIVFolderSelect | ID / row_id | Dynamic first, Global ID conversion, scope filtering |
| Custom Select | Name | Can use Pattern B even with name if dynamic control needed |
Implementation Checklists
Pattern A Checklist (Name-Based Value)
Component Setup:
GraphQL Fragment:
graphql\`
fragment YourComponent_fragment on Query
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "String" }
filter: { type: "YourFilterType" }
)
@refetchable(queryName: "YourComponentPaginationQuery") {
yourEntities(first: $first, after: $after, filter: $filter)
@connection(key: "YourComponent_yourEntities") {
count
edges {
node {
id
name
}
}
}
}
\`
Required Checklist:
BAISelect Integration:
Pattern B Checklist (ID-Based Value)
Component Setup:
State Management:
GraphQL Queries:
Query 1 - Selected Values (with Dynamic First):
graphql\`
query YourComponentValueQuery(
$selectedFilter: String
$first: Int!
$skipSelected: Boolean!
) {
yourEntities(filter: $selectedFilter, first: $first)
@skip(if: $skipSelected) {
edges {
node {
id
row_id
name
}
}
}
}
\`
// Variables
{
selectedFilter: /* filter based on selected values */,
first: _.castArray(deferredControllableValue).length, // š Dynamic
skipSelected: _.isEmpty(deferredControllableValue),
}
Query 2 - Paginated Options:
graphql\`
query YourComponentPaginatedQuery(
$offset: Int!
$limit: Int!
$filter: String
) {
yourEntities(
offset: $offset
first: $limit
filter: $filter
order: "-created_at"
) {
count
edges {
node {
id
row_id
name
}
}
}
}
\`
Query Checklist:
Value-to-Label Mapping:
Optimistic Updates:
BAISelect Integration:
Ref Export:
Common Patterns
showSearch Configuration
Both patterns should implement flexible showSearch handling to support:
- Disabling search completely with
showSearch={false}
- Merging user-provided
showSearch configurations
- Using optimistic search strings for immediate feedback
Pattern A: Basic showSearch
const [searchStr, setSearchStr] = useState<string>();
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
searchAction={async (value) => {
setOptimisticSearchStr(value);
setSearchStr(value);
selectRef.current?.scrollTo(0);
refetch({ filter: value ? { name: { contains: value } } : null });
await selectProps.searchAction?.(value);
}}
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
Pattern B: Advanced showSearch with debouncing
const [searchStr, setSearchStr] = useState<string>();
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr);
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
searchAction={async (value) => {
setOptimisticSearchStr(value);
setSearchStr(value);
await selectProps.searchAction?.(value);
}}
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
Why this pattern?
- ā
Flexibility: Parent can disable search with
showSearch={false}
- ā
Extensibility: Parent can provide custom
showSearch config (except searchValue)
- ā
Consistency: All relay select components follow the same pattern
- ā
Optimistic UI: Uses
optimisticSearchStr for immediate feedback
- ā
Type Safety: TypeScript catches invalid configurations
Usage Examples:
<BAIUserSelect showSearch={false} />
<BAIUserSelect
showSearch={{
placeholder: "Search by email...",
maxLength: 50,
}}
/>
<BAIUserSelect />
Common Pitfalls:
showSearch={{
autoClearSearchValue: true,
filterOption: false,
}}
showSearch={{
searchValue: searchStr,
autoClearSearchValue: true,
filterOption: false,
}}
showSearch={{
}}
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
Dynamic First Parameter (Default in Pattern B)
Pattern B always uses dynamic first parameter to ensure all selected values are fetched:
const { entity_nodes: selectedNodes } =
useLazyLoadQuery<YourComponentValueQuery>(
graphql`
query YourComponentValueQuery(
$selectedFilter: String
$first: Int!
$skipSelected: Boolean!
) {
entity_nodes(filter: $selectedFilter, first: $first)
@skip(if: $skipSelected) {
edges {
node {
id
name
}
}
}
}
`,
{
selectedFilter: ,
first: _.castArray(deferredControllableValue).length,
skipSelected: _.isEmpty(deferredControllableValue),
},
);
Why this is the default in Pattern B:
- ā
Fetch exactly the number of selected items
- ā
No over-fetching (performance)
- ā
No under-fetching (data completeness)
- ā
Works with any selection count (1, 10, 100, etc.)
- ā
Essential for multiple selection mode
- ā
Prevents data loss when users select many items
Without dynamic first (NOT Pattern B):
first: 10
first: _.castArray(deferredControllableValue).length
Multiple Mode Support
const valueArray = _.isEmpty(value) ? [] : _.castArray(value);
valueArray.map((value) => {
});
Key Points:
- Use
_.isEmpty() to check for empty value before casting
_.castArray() ensures uniform handling of single and multiple modes
- Prevents issues when value is undefined or empty array
Search with Transitions
const [searchStr, setSearchStr] = useState<string>();
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
searchAction={async (value) => {
setOptimisticSearchStr(value);
setSearchStr(value);
selectRef.current?.scrollTo(0);
refetch({ filter: value ? { name: { contains: value } } : null });
await selectProps.searchAction?.(value);
}}
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
import useDebouncedDeferredValue from '../../helper/useDebouncedDeferredValue';
const [searchStr, setSearchStr] = useState<string>();
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr);
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
filter: mergeFilterValues([
mergedFilter,
debouncedDeferredValue ? `email ilike "%${debouncedDeferredValue}%"` : null,
])
searchAction={async (value) => {
setOptimisticSearchStr(value);
setSearchStr(value);
await selectProps.searchAction?.(value);
}}
showSearch={
selectProps.showSearch === false
? false
: {
searchValue: optimisticSearchStr,
autoClearSearchValue: true,
filterOption: false,
...(_.isObject(selectProps.showSearch)
? _.omit(selectProps.showSearch, ['searchValue'])
: {}),
}
}
loading={
loading ||
controllableValue !== deferredControllableValue ||
searchStr !== debouncedDeferredValue ||
isPendingRefetch
}
Why useDebouncedDeferredValue + useOptimistic for search?
- ā
Input field shows optimistic value immediately (best UX)
- ā
Debounce reduces query frequency during fast typing (200ms default)
- ā
useDeferredValue prevents UI blocking during query execution
- ā
Query uses
debouncedDeferredValue (debounced + deferred state)
- ā
Works seamlessly with BAISelect's built-in startTransition wrapper
- ā
React automatically manages the complete state transition flow
- ā
Loading indicator shows during debounce + defer period
- ā
Best balance between responsiveness and performance
- ā
Prevents excessive GraphQL queries during fast typing
Global ID Conversion
import { toLocalId, toGlobalId } from '../../helper';
const filterValue = valuePropName === 'id' ? toLocalId(value) : value;
Filter Merging
import { mergeFilterValues } from '../BAIPropertyFilter';
const filter = mergeFilterValues([
baseFilter,
searchStr ? \`name ilike "%\${searchStr}%"\` : null,
externalFilter,
], '&'); // Default operator
Controllable Props
const [value, setValue] = useControllableValue(props);
const [open, setOpen] = useControllableValue(props, {
valuePropName: 'open',
trigger: 'onOpenChange',
});
Pattern B: Dynamic Query Parameters
Core Capabilities
Pattern B (Dynamic) provides full control over GraphQL query parameters through props:
1. Dynamic First Parameter (Default Behavior)
first: _.castArray(deferredControllableValue).length
This is not optional - it's the defining characteristic of Pattern B that ensures data completeness.
2. Dynamic Filter
filter={mergeFilterValues([
'status == "ACTIVE"',
searchStr ? `name ilike "%${searchStr}%"` : null,
props.filter,
])}
3. Dynamic Limit
{ limit: props.pageSize || 10 }
4. Dynamic Order
order: props.sortBy || '-created_at'
5. External Refetch
const selectRef = useRef<YourSelectRef>(null);
selectRef.current?.refetch();
When Pattern B is Essential
Even if your value is a simple name (not ID), use Pattern B when you need:
- ā
Dynamic filter from parent component
- ā
External refetch capability
- ā
Control over fetchPolicy
- ā
Multiple selection with optimistic updates
- ā
Dynamic pagination size
- ā
Custom sort order
Example: Name-based but needs Pattern B
<YourEntitySelect
value={selectedNames}
filter={externalFilter}
pageSize={20}
sortBy="name"
ref={selectRef}
/>
Best Practices
Performance
-
Use 'use memo' directive (Pattern B)
const YourSelect: React.FC<Props> = (props) => {
'use memo';
};
-
Defer values to prevent Suspense flicker
const deferredOpen = useDeferredValue(open);
const deferredValue = useDeferredValue(value);
const deferredFetchKey = useDeferredValue(fetchKey);
Search optimization with useDebouncedDeferredValue (Recommended):
import useDebouncedDeferredValue from '../../helper/useDebouncedDeferredValue';
const [searchStr, setSearchStr] = useState<string>();
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr, {
wait: 200,
});
const [optimisticSearchStr, setOptimisticSearchStr] =
useOptimistic(searchStr);
Why useDebouncedDeferredValue?
- ā
Combines
useDebounce + useDeferredValue for optimal search performance
- ā
Debounces user input to reduce query frequency (default 200ms)
- ā
Defers query execution to prevent UI blocking
- ā
Use
optimisticSearchStr for immediate input feedback
- ā
Use
debouncedDeferredValue in query filters
- ā
Better performance than debounce or defer alone
- ā
Prevents excessive GraphQL queries during fast typing
Pattern comparison:
const deferredSearchStr = useDeferredValue(searchStr);
const debouncedSearchStr = useDebounce(searchStr);
const debouncedDeferredValue = useDebouncedDeferredValue(searchStr);
-
Optimize fetchPolicy
fetchPolicy: !_.isEmpty(value) ? 'store-or-network' : 'store-only'
fetchPolicy: deferredOpen ? 'network-only' : 'store-only'
-
Skip unnecessary queries
@skip(if: $skipSelected)
UX
-
Always scroll to top on search
selectRef.current?.scrollTo(0);
-
Maintain selection order
_.castArray(deferredValue)
.map((v) => findEdge(v))
.filter(Boolean);
-
Handle React element labels
const label = _.isString(v.label)
? v.label
: (options.find((opt) => opt.value === v.value)?.label ?? v.value);
-
Custom label rendering for IDs
import { toLocalId } from '../../helper';
import BAIText from '../BAIText';
labelRender={({ label }) => {
return valuePropName === 'id' && _.isString(label) ? (
<BAIText monospace>{toLocalId(label)}</BAIText>
) : (
label
);
}}
optionRender={({ label }) => {
return valuePropName === 'id' && _.isString(label) ? (
<BAIText monospace>{toLocalId(label)}</BAIText>
) : (
label
);
}}
Benefits:
- Convert Global IDs to local IDs for better readability
- Consistent monospace rendering for ID values
- Conditional rendering based on
valuePropName
-
Loading state priorities
loading={
loading ||
controllableValue !== deferredControllableValue ||
searchStr !== debouncedDeferredValue ||
isPendingRefetch
}
Common Pitfalls & Solutions
| Pitfall | Impact | Solution |
|---|
Missing first parameter | Incomplete selected values | REQUIRED in Pattern B: Add $first: Int! parameter |
Hardcoded first: 10 | Missing data for >10 selections | Pattern B always uses: _.castArray(value).length |
Not checking _.isEmpty() before _.castArray() | Potential issues with empty values | Use _.isEmpty(value) ? [] : _.castArray(value) |
Missing _.castArray | Single mode breaks | Always normalize values |
Not using deferredValue | Suspense flicker | Defer controllable values (value, open, fetchKey) |
Not using useOptimistic for search | Poor search UX | Use useOptimistic for immediate feedback |
Not using useDebouncedDeferredValue | Too many queries | Use useDebouncedDeferredValue for search |
| Missing loading condition | No loading feedback | Add searchStr !== debouncedDeferredValue |
Missing @skip directive | Unnecessary queries | Add skip when empty |
| Not preserving labels | Lost labels on tag removal | Check if label is string or element |
| Hardcoded valuePropName | Inflexible component | Use prop: 'id' | 'row_id' |
| Direct option mutation | Stale data | Rebuild from query results |
| No scroll on search | Poor UX | Call selectRef.current?.scrollTo(0) |
| Wrong fetchPolicy | Performance issues | Use appropriate policy per query |
Hardcoded showSearch object | Can't disable search | Use conditional pattern with showSearch === false check |
Not using optimisticSearchStr | Delayed search feedback | Use useOptimistic for searchValue |
Not merging user showSearch | Inflexible configuration | Merge with _.omit(selectProps.showSearch, ['searchValue']) |
TypeScript Patterns
Props Interface
export interface YourComponentSelectProps
extends Omit<BAISelectProps, 'options' | 'labelInValue'> {
queryRef?: YourFragment$key;
valuePropName?: 'id' | 'row_id';
filter?: string;
ref?: React.Ref<YourComponentRef>;
}
Ref Interface (Pattern B)
export interface YourComponentRef {
refetch: () => void;
}
Type Extraction (Pattern B)
export type YourEntityNode = NonNullable<
NonNullable<
YourPaginatedQuery['response']['yourEntities']
>['edges'][number]
>['node'];
Real-World Examples
Example 1: Simple Entity Selection (Pattern A)
<BAIAdminResourceGroupSelect
queryRef={queryRef}
placeholder="Select resource group"
onChange={(name) => setSelectedGroup(name)}
/>
Example 2: Multiple Selection with ID (Pattern B)
const vfolderSelectRef = useRef<BAIVFolderSelectRef>(null);
<BAIVFolderSelect
ref={vfolderSelectRef}
valuePropName="id"
mode="multiple"
value={selectedFolderIds}
onChange={setSelectedFolderIds}
onClickVFolder={(id) => navigate(\`/folders/\${id}\`)}
/>
<Button onClick={() => vfolderSelectRef.current?.refetch()}>
Refresh
</Button>
Example 3: With External Filters (Pattern B)
<BAIVFolderSelect
filter={mergeFilterValues([
'status != "DELETE_COMPLETE"',
ownershipFilter ? \`ownership_type == "\${ownershipFilter}"\` : null,
])}
excludeDeleted
onChange={(ids) => handleSelection(ids)}
/>
Quick Reference
When to Use Which Pattern
Pattern A (Simple) when:
- ā
Simple, static requirements
- ā
Single selection sufficient
- ā
No need for dynamic query parameters
- ā
Minimal code preferred
- ā
No external refetch needed
Pattern B (Dynamic) when:
- ā
Need dynamic query parameters (filter, first, limit, order, etc.)
- ā
Multiple selection required
- ā
Need external refetch capability via ref
- ā
Need to control fetchPolicy dynamically
- ā
Optimistic UI updates important
- ā
Complex filter combinations needed
- ā
Value different from display name (or needs special handling)
- ā
Even name-based values if dynamic control needed
Key insight: Pattern B is not about the value type (email vs ID vs name), but about dynamic control over query parameters and component behavior.
Reference Files
- Pattern A (Simple):
references/patterns/BAIAdminResourceGroupSelect.md
- Pattern B (Dynamic):
- Email-based with Dynamic First:
references/patterns/BAIUserSelect.md
- ID-based with Dynamic First & Global ID Conversion:
references/patterns/BAIVFolderSelect.md
- Base Component:
references/base/BAISelect.md
- Hooks:
references/hooks/ (useFetchKey, useLazyPaginatedQuery, useDebouncedDeferredValue, useEventNotStable)
- Helpers:
references/helpers/ (relay-helpers, mergeFilterValues)
File Structure
YourEntitySelect.tsx
āāā Imports (React, Relay, hooks, helpers)
āāā Type definitions (Props, Ref, Node extraction)
āāā Component with 'use memo'
ā āāā State management
ā āāā GraphQL queries
ā āāā Value-to-label mapping (Pattern B)
ā āāā useImperativeHandle (Pattern B)
ā āāā Options building
ā āāā BAISelect integration
āāā Export
Internationalization
Use comp: prefix for component translations:
t('comp:YourComponentSelect.PlaceHolder')
t('comp:YourComponentSelect.SelectEntity')
t('comp:YourComponentSelect.NoEntityFound')
Additional Resources
For comprehensive examples and detailed implementation, refer to:
references/README.md - File overview and usage notes
- Full pattern implementations in
references/patterns/
- Base component API in
references/base/
- Hook documentation in
references/hooks/
- Helper utilities in
references/helpers/