reactjsreact-reduxredux-toolkitrtk-query

How to use RTK Query in combination with Selectors


I'm building a project using Redux Toolkit with RTK Query and I am trying to get some entries from an API. I'm using the normalized data approach with the createEntityAdapter and because in a certain component I need the data as an array I have ended up using selectors. Now my issue is that since I added filters as a parameter for my query, my selector stopped working.

I have studied similar questions here, like: How to use RTK query selector with an argument?, but I'm just too dumb to understand what I am supposed to modify. I have tried to make sense of the RTK Query Docs but I couldn't.

From the question above I got that my selector needs to also have the params in order to know what to select exactly and that it is not a recommended pattern but I couldn't understand how to make it work.

My entry slice:

import { createSelector, createEntityAdapter } from '@reduxjs/toolkit'
import { apiSlice } from './apiSlice'

const entryAdapter = createEntityAdapter()

const initialState = entryAdapter.getInitialState({
  ids: [],
  entities: {},
})

export const entryApiSlice = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    initialState,
    getEntry: builder.query({
      query: (filters) => ({
        url: '/history',
        params: filters,
      }),
      transformResponse: (responseData) => {
        return entryAdapter.setAll(initialState, responseData)
      },
      providesTags: (result, error, arg) => [
        { type: 'Entry', id: 'LIST' },
        ...result.ids.map((id) => ({ type: 'Entry', id })),
      ],
    }),
    addEntry: builder.mutation({
      query: (data) => ({
        url: '/history/new',
        method: 'POST',
        body: data,
      }),
      invalidatesTags: [{ type: 'Entry', id: 'LIST' }],
    }),
    updateEntry: builder.mutation({
      query: (initialEntry) => ({
        url: `/history/${initialEntry.Id}`,
        method: 'PUT',
        body: {
          ...initialEntry,
          date: new Date().toISOString(),
        },
      }),
      invalidatesTags: (result, error, arg) => [{ type: 'Entry', id: arg.id }],
    }),
    deleteEntry: builder.mutation({
      query: ({ id }) => ({
        url: `/history/${id}`,
        method: 'DELETE',
        body: { id },
      }),
      invalidatesTags: (result, error, arg) => [{ type: 'Entry', id: arg.id }],
    }),
  }),
})

export const {
  useGetEntryQuery,
  useAddEntryMutation,
  useUpdateEntryMutation,
  useDeleteEntryMutation,
} = entryApiSlice

export const selectEntryResult = (state, params) =>
  entryApiSlice.endpoints.getEntry.select(params)(state).data

const entrySelectors = entryAdapter.getSelectors(
  (state) => selectEntryResult(state) ?? initialState
)
export const selectEntry = entrySelectors.selectAll

And I'm using it in a Entries component like this

  const {
    data: entriesData = [],
    refetch,
    isLoading,
    isSuccess,
    isError,
    error,
  } = useGetEntryQuery(filters)

  const entries = useSelector(selectEntry)

Note: If I remove the filters from the get query everything works as before (as expected of course).

Disclaimer: I do not know what I am doing exactly, I have read the docs and I'm trying to figure it out, so any feedback is very much appreciated.
Thank You!


Solution

  • Yeah this is a little touchy subject since RTKQ's docs tend to show the most simple example, i.e. queries which do not use any parameters at all. I've had a lot of issues with this myself.

    Anyway, you've declared your selectEntryResult as a function of two parameters: state and params. Then, when you create adapter selectors just below that you're calling it with only one parameter: state. Furthermore, when you use your final selector in your component as so:

    const entries = useSelector(selectEntry);
    

    the parameters are nowhere to be found, making them undefined by default, which fails to find any data associated with such query params.

    The key thing to understand here is that you need somehow pass the query parameters practically through every level of your selectors (every wrapper).

    One approach here is to 'forward' parameters through your selector:

    export const selectEntryResult = createSelector([state => state, (_, params) => params], (state, params) =>
      entryApiSlice.endpoints.getEntry.select(params)(state)?.data ?? initialState)
    

    Here we make use of createSelector function exported from RTK. Then in your component you would do something like this:

      const {...} = useGetEntryQuery(filters);
    
      const entries = useSelector(state => selectEntry(state, filters));
    

    This will work when using the selectAll selector created by your entity adapter, however it will cause issues when using selectById since that selector is also parametrized. In a nutshell, the selectById selector is defined internally to use the second argument for the id of the entity you wish to retrieve, while the approach I showed uses the second argument to pass query parameters (filters in your case).

    From what I've seen so far there is no solution that works perfectly and covers all of the following:

    A different approach may be to create some selector factories which dynamically create base selectors for a specific combination of query parameters.

    I have once made such a wrapper that enables usage in all cases. I cannot share it unfortunately as it is a private package, but the basic idea was to shift around parameters such that both selectById and selectAll (and all other selectors) can work properly, by passing the query parameters as the third parameter to the selector, and then re-wrapping every entity adapter selector further:

    export const createBaseSelector = (endpoint) =>
      createSelector(
        [(state) => state, (_, other) => other, (_, _other, params) => params],
        (state, _other, params) => endpoint.select(params)(state)
      );
    
    const selectors = adapter.getSelectors(baseSelector);
    
    // And then rewrap selectAll and selectById from 'selectors' above
    

    I know it sounds complicated, I barely got it working, so try to avoid going in this direction :)

    A helpful article I found along the way can be found here, they also describe some approaches with creating selectors at component-level and memoizing them, but I've not tried it all. Have a look, maybe you find an easier way to solve your specific problem.