useSearchList Custom Hook

Overview:

The useSearchableList hook is a Custom React Hook designed to provide a reusable and customizable list-fetching mechanism for react-admin applications.

It addresses the limitation of react-admin filters being applied only to the current page, ensuring that searches can span across multiple pages. This is particularly useful when dealing with large datasets where items might not be present on the current page.

The problem that were facing before creating this hook it that the react admin filters were only getting applied to the current page. So for example, if a user on the list page and also was on page number (1) and tries to search for an item that was on page number (10), they will get no results

UseSearchableList Hook Implementation

State and Initial data fetching

const [payload, setPayload] = useState({});

const { basePath, defaultSearchFilter, defaultFilter, defaultPerPage, resource, defaultSort } = props;

nearly every function in this hook depends on the payload and the props

I will list below what every thing is used for:

defaultSearchFilter(OPEN SEARCH)the default filters that you want to append when searching via OpenSearch
defaultFilter(DynamoDB)the default filters that you want to append when pulling data normally from DynamoDB
defaultPerPagedefault items per page
resourceThe resource name (DB Name)
defaultSortthe default sort is the default sort that is going to be used for both OpenSearch and DynamoDB

Hooks:

When the hook execute it runs additional custom hooks

useQueryWithStore (React-admin – Querying the API)

React-admin exposes a more powerful version of useQuery. useQueryWithStore persist the response from the dataProvider in the internal react-admin Redux store, so that result remains available if the hook is called again in the future.

You can use this hook to show the cached result immediately on mount, while the updated result is fetched from the API. This is called optimistic rendering.

~ react-admin docs

const { data, error, loaded, loading, total } = useQueryWithStore({ type: 'getList', resource, payload });

this hook pulls the data from DynamoDB via GraphQL just by passing to it the type of query that we want and the resource, and the payload to add the filters, pagination, and sort.

useList (React-admin – useList)

The useList hook allows to create a ListContext based on local data. useList creates callbacks for sorting, paginating, filtering, and selecting records from an array.

Thanks to it, you can display your data inside a <Datagrid>, a <SimpleList> or an <EditableDatagrid>

~react-admin docs

const listContext = useList({ ...props, data: data || [], ids: data?.map(itm => itm.id) || [], loading, total, sort: defaultSort, perPage: defaultPerPage, }); {page, perPage, filterValues, currentSort} = listContext;

we are using the useList to have a way to display the data inside the List Component in react-admin, since we are pulling data manually by useQueryWithStore

useVersion()

this is simply returns to you the redux version and by that it keeps track of any changes that happens in the redux store


Functions:

keyQueryFetch:

const keyQueryFetch = (queryBy, order) => { setPayload({ filter: {[queryBy]: { ...defaultFilter }}, pagination: {page: listContext.page, perPage: listContext.perPage}, sort: {field: queryBy, order: order}, }); }

This functions simply update the payload state, this also trigger a query by normal DynamoDB filters not OpenSearch

searchableFetch:

const searchableFetch = (sortBy, order) => { const searchInput = { filter: { ...listContext.filterValues, ["search" + capitalizeFirstLetter(getResouceName(resource))]: { ...defaultSearchFilter, }, }, pagination: {page: listContext.page, perPage: listContext.perPage} } if (sortBy) searchInput.sort = {field: sortBy, order: order.toLowerCase()}; setPayload(searchInput); }

This nearly does the same thing as the keyQueryFetch functions it updates the payload state, the difference is that this append 'search' string before resource name.

This triggers the OpenSearch request.

refetch

const refetch = () => { let sortQuery; if (listContext.currentSort?.field?.startsWith("{")) { sortQuery = JSON.parse(listContext.currentSort.field); } else { sortQuery = {search: listContext.currentSort.field}; } if (isEmpty(listContext.filterValues) && sortQuery?.key) { keyQueryFetch(sortQuery.key, listContext.currentSort.order); } else { searchableFetch(sortQuery.search, listContext.currentSort.order); } }

This refetch function first tries to create a parse the sortQuery from listContext and get the currentSort field from there

after that it checks if we have in the listContext has filterValues or not.
if so it has it will call the searchableFetch() (OpenSearch) else it will call keyQueryFetch() (DynamoDB)


useEffect (Flow):

First useEffect, dependencies [version]

const version = useVersion(); useEffect(() => {   const sortQuery = JSON.parse(defaultSort.field);   keyQueryFetch(sortQuery.key, defaultSort.order); }, [version]);

Since this is the first useEffect it will run on mounting. but what is does is that it will parse the defaultSort that is passed as props. and call the keyQueryFetch with the sortQuery key (sortQuery.key) and defaultSort order (defaultSort.order). which fetch data by DynamoDB

Second useEffect, dependencies [perPage, filterValues]

useEffect(() => { if (!loading) { listContext.page = 1; listContext.setPage(1); } }, [listContext.perPage, listContext.filterValues]);

This will run whenever we change perPage number or change the filterValues. to make sure that we start from the beginning.

Third useEffect, dependencies [page, perPage, filterValues, currentSort, defaultFilter]

useEffect(() => { if (!loading) {   refetch(); } }, [ listContext.page, listContext.perPage, listContext.filterValues, listContext.currentSort, defaultFilter ]);

this is called whenever there is any change from on the list.
whether we are navigating to the next page (page), or choosing larger number to view per page (perPage), or changing the filter by searching something for example (filterValues), or changing the defaultFilter.


UseSearchableList Hook Usage

The implementation was quite a lot to digest but the usage is luckily quite simpler.

Lets say we have a User model in the database that we want to use with this useSearchableList Hook. First we need to add @searchable to the Model in graphQL file schema.graphql

type User @model @searchable @key( name: "userByEmail" fields: ["email", "active"] queryField: "userByEmail" ) { id: ID! active: Boolean! name: String! email: String! }

That is it for the model part.

In you React Admin List

you need first to use <ListContextProvider> instead of <List value={listContext} >`

n React Admin, ListContextProvider is a context provider component used to supply the necessary data and methods to components that are part of a list view. It enables sharing the list-related state and actions across multiple components in a list view, such as the Datagrid, Pagination, Filter, and others.

~ react-admin docs

You need also to have the constants and states ready before calling useSearchableList

Default Sort

const defaultSort = { field: `{"key":"userByEmail", "search":"email"}`, order: "ASC" };

Default Filter

const [defaultFilter] = useState({active: true});

Default Search Filter

const defaultSearchFilter = { active: { eq: true }};

Per Page

const sessionPerPage = sessionStorage.getItem(`${props.resource}_perPage`); const [defaultPerPage, setDefaultPerPage] = useState(sessionPerPage ? Number(sessionPerPage) : 10);

here we get the session saved perPage Value for this resource. and then save it in a state

Calling UseSearchable Hook

const listContext = useSearchableList({ ...props, defaultFilter: defaultFilter, defaultSort: defaultSort, defaultSearchFilter: defaultSearchFilter, defaultPerPage: defaultPerPage, resource: "userByEmail", });

right now we are ready to list the data

<ListContextProvider value={listContext} > <Title title={<ForceTitle title="Help Pages" />} /> <ListToolbar {...props} filters={getListFilters(gteMedium)}     actions={<ListActions isXSmall={isXSmall} />} /> <Card>   <Datagrid rowClick="edit" >     <TextField source="email" label="Email" sortable={true}       sortBy={`{"key":"userByEmail", "search":"email"}`} /> <TextField source="name" label="Name" sortable={true} /> </Datagrid>     <Pagination {...listContext} /> </Card> </ListContextProvider>

UseEffects

there are some use effects that can be used

First

 useEffect(() => { if (listContext?.perPage) { sessionStorage.setItem(`${props.resource}_perPage`, listContext.perPage);   setDefaultPerPage(Number(listContext.perPage)); } }, [listContext?.perPage]);

this check and saved the perPage values in the session

Second

useEffect(() => { if (listContext?.filter) {   const fieldObj = JSON.parse(defaultSort.field);     const keyToCheck = fieldObj.key; const hasKey = keyToCheck in listContext.filter;     const hasFilterValues = Object.keys(listContext.filterValues).length > 0; if (hasKey && hasFilterValues) listContext.setFilters({}); } }, [listContext?.filter]);

this runs in listContext.filter changes it checks if the filters are empty or not if they are we listContext.setFilters({}) to empty Object

Search Field

const HelpSearchField = ({ label, alwaysOn, ...props }) => {     const listContext = useListContext(props);     const { displayedFilters, filterValues, setFilters } = listContext;     const handleSearch = (value) => {         if (value) {             delete filterValues.or;             const filters = {                 ...filterValues,                 or: [                     { name: { matchPhrasePrefix: `${value}` }},                     { name: { wildcard: `*${value}*` }},                     { email: { matchPhrasePrefix: `${value}` }},                     { email: { wildcard: `*${value}*` }},                 ]             };             setFilters(filters, displayedFilters);         } else {             delete filterValues.or;             setFilters(filterValues, displayedFilters);         }     }     return (         <SearchField id="searchTerm" label={label} margin="dense" variant="outlined"             alwaysOn={alwaysOn}             className={classes.searchField}             handleSearch={handleSearch}         />     ) }

The HelpSearchField component is a custom search input that allows users to filter the list based on search terms. It integrates with the React Admin list context to manage and apply the search filters effectively.

useListContext This hook is used to access the list context provided by React Admin. It allows the HelpSearchField component to interact with the list’s state and actions.

The handleSearch function is called whenever the user types into the search field. It updates the list filters based on the input value. If the input is not empty, it creates a set of or conditions to search across multiple fields (name, and email) using both matchPhrasePrefix and wildcard queries. If the input is empty, it removes the or filter from the filterValues.

OpenSearch Query Explanation

OpenSearch is a search and analytics engine that allows for powerful and flexible search capabilities. In this context, the or array is used to create a compound query that searches for the provided value (${value}) across multiple fields (name and email) using different search types (matchPhrasePrefix and wildcard).

  • matchPhrasePrefix: This query type is used to find documents that contain the exact phrase specified by ${value}, starting at the beginning of the field. It is useful for autocomplete or type-ahead search features.
  • wildcard: This query type uses wildcards to match any sequence of characters. The asterisks (*) act as wildcards, meaning that ${value} can appear anywhere in the field.

Related Content