Skip to content

Instantly share code, notes, and snippets.

@apcomplete
Last active April 21, 2023 00:53
Show Gist options
  • Save apcomplete/a2620c2a9d507acfc5a415ebde9a42a8 to your computer and use it in GitHub Desktop.
Save apcomplete/a2620c2a9d507acfc5a415ebde9a42a8 to your computer and use it in GitHub Desktop.

RTK Query

What is RTK Query?

  • RTK Query is a powerful data fetching and caching tool. It is designed to simplify common cases for loading data in a web application, eliminating the need to hand-write data fetching & caching logic yourself.
  • It's built on top of RTK, namely createAsyncThunk and createSlice.

TLDR: Abstraction of fetching logic + caching

What are the major features of RTK Query?

API/Redux Integration

  • Manages the Redux store, taking things returned from the API and storing them in the API slice.

Caching

RTK Query is a document cache, not a normalized cache.

RTK Query deliberately does not implement a cache that would deduplicate identical items across multiple requests. There are several reasons for this:

  • A fully normalized shared-across-queries cache is a hard problem to solve
  • We don't have the time, resources, or interest in trying to solve that right now
  • In many cases, simply re-fetching data when it's invalidated works well and is easier to understand
  • At a minimum, RTKQ can help solve the general use case of "fetch some data", which is a big pain point for a lot of people

Each of these examples would be cached differently:

getTodos()

getTodos({ filter: 'odd' })

getTodo({ id: 1 })

We can still do optimistic cache updates to update the UI quickly while a request is happening.

Hooks

RTK Query provides hooks that manage fetching the data through React's lifecycle.

The hooks provide an object providing way more detail for each query/mutation that we would ordinarily have without writing tons of boiler plate.

Each query/mutation returns:

data - the actual response contents from the server.

isLoading - a boolean indicating if this hook is currently making the first request to the server.

isFetching - a boolean indicating if the hook is currently making any request to the server

isSuccess - a boolean indicating if the hook has made a successful request and has cached data available (ie, data should be defined now)

isError - a boolean indicating if the last request had an error

error - a serialized error object

Benefits/Why is this a good fit for us?

  1. Reduces boilerplate by a LOT.
    • No more writing slices to hold data
    • No more writing duplicate methods to separate API calls from the actions that call them
  2. Managing API/Redux integration in turn removes data from Redux slices, focusing our interactions with Redux solely on managing application state.
  3. Manages integration with React lifecycle (See: React docs)
  4. No more component tree coupling/dependency chaining for fetching data stored in redux.
  • Hooks embody both what the data is as well as how to get it. In the case that a call to a RTKQ hook in a parent component gets removed, any other hook in the children will initiate the same call.

Example

Consider the following API functions:

const getReports = (
  league: LeagueAbbreviation,
): Promise<{
  reports: ShortReport[];
}> => {
  return http.get('/api/v1/reports', { params: { league } });
};

const getFullReport = (
  report: { actions: { self: string } },
): Promise<SerializedReport> => {
  return http
    .get(report.actions.self)
    .then(({ report }: { report: MetadataResponse }) => serializeReportResponse(report));
};

We need/use the following functions to fetch them:

useFullReport.ts

const useFullReport = (shortReport: ShortReport) => {
  const report = useAppSelector(selectors.getCurrentReport);

  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const getFullReport = useDebouncedCallback((id: number) => {
    setLoading(true);
    setError(null);
    ReportsAPI.getFullReport(id)
      .then((fullReport) => {
        dispatch(actions.setCurrentReport(fullReport));
      })
      .catch((e) => {
        console.error('Could not request report');
        setError(e);
      })
      .finally(() => {
        setLoading(false);
      });
  }, 250);
  
  useEffect(() => {
    getFullReport(shortReport.id);
  }, [shortReport.id]);

  return { loading, report, error };
};

actions.ts

export const fetchReports = (
  league: LeagueAbbreviation,
): ThunkAction<Promise<void>, RootState, undefined, AnyAction> => {
  return async (dispatch) => {
    dispatch(actions.setLoading(true));
    try {
      const response = await ReportsAPI.getReports(league);
      dispatch(actions.setReports(response));
    } finally {
      dispatch(actions.setLoading(false));
    }
  };
};

RTK Query APIs provide hooks for each query or mutation.

api.ts

const api = createApi({
  baseQuery: fetchBaseQuery({
    baseUrl: '/api/v1',
  }),
  tagTypes: ['Report'],
  endpoints: (build) => ({
    getReportsForLeague: build.query<ShortReport[], LeagueAbbreviation>({
      query: (league) => ({ url: `reports?league=${league}` }),
      transformResponse: (response: { reports: ShortReport[] }, _meta, _arg) => response.reports,
      providesTags: ['Report']
    })
    getReport: build.query<SerializedReport, number>({
      query: (id) => ({ url: `reports/${id}` }),
      transformResponse: (response: { report: MetadataResponse }, _meta, _arg) => serializeReportResponse(response.report),
      providesTags: (result, error, id) => [{ type: 'Report', id }],
    })
    
 export const { useGetReportsQuery, useGetReportQuery };

useReports.tsx

// prevents reports from ever being undefined
const emptyArray: Report[] = [];

const useReports = (league: LeagueAbbreviation) => {
  const { data, isLoading, isFetching, isError } = useGetReportsQuery(league);
  return { reports: data?.reports ?? emptyArray, loading: isLoading, isFetching, isError, error };
};

useFullReport.tsx

const useFullReport = (shortReport: ShortReport) => {
  const { data, isLoading, isFetching, isError, error } = useGetReportQuery(shortReportid);
  return { report: data?.report, loading: isLoading, isFetching, isError, error };
};

Suggested Patterns

Abstracting data source of hooks

Currently, we've allowed useAppSelector and useAppDispatch to propagate into nearly every Redux-connected component in our application. This makes modifying our usage pattern for Redux, or even getting off of Redux all together very difficult. RTK Query provides hooks and they are a great starting point, but we should wrap them to abstract the source of the data and properly encapsulate the domain.

Prefer

const useReportById = (id: number) => {
  const { data, isLoading, isFetching, isError, error } = useGetReportQuery(id);
  return { report: data?.report, loading: isLoading, isFetching, isError, error };
};
const useReorderReports = () => { 
  const { reorderReports, isLoading } = useReorderReportsMutation();
  return { reorderReports, loading: isLoading };
};

Do not prefer

const report = useAppSelector(selectors.getReportById(id));
const dispatch = useAppDispatch();
const reports = useAppSelector(selectors.getReports);

const onReorder = () => {
  dispatch(actions.reorderReports(reports));
};

Testing with Mock Service Worker

We often mock API functions, eliminating the code that actually touches fetch from the test. With MSW we can stand up a mock server and actually hit the server with the endpoint under test, getting a fully integrated test.

Prefer

describe('when reordering reports throws an error', () => {
  beforeEach(() => {
    server.use(
      rest.post('/api/v1/reports/reorder', async (_req, res) => {
        return res(ctx.json(422), ctx.json({ errors: { 'reports.0.display_order': ["can't be null"] } });
      })
    );
  });

Do not prefer

// mocking the entire API
jest.mock('js/api/reports');

describe('when reordering reports throws an error', () => {
  beforeEach(() => {
    jest.mocked(ReportsAPI).reorderReports.mockRejectedValue(
      new Error({ 
        errors: { 
          'reports.0.display_order': ["can't be null"] 
         }
      })
    );
  });

Chattier APIs to reduce loading times and keep data fresh

Since collections are cached separately from individual requests by nature of the document store, we don't have to do big fetches to all the data we want up front anymore. We can simply return the data needed to render the index page and then on the show/edit page request the longer form version from the show page.

report_view.ex

def render("index.json", %{reports: reports}) do
  %{reports: render_many(reports, __MODULE__, "short.json")}
end

def render("show.json", %{report: report}) do
  %{report: render_one(report, __MODULE__, "long.json")}
end

Index.tsx

const Index = () => {
  const { reports, loading } = useReports();
  ...
};

Edit.tsx

const Edit = (id: number) => {
  const { report, loading } = useReportById(id);
  ...
};

However, in the case that the index shares the same shape as the show page. We can use selectFromResult selectors so-as not to make another call when unnecessary:

const useReportById = (id: number) => {
  const { data, isLoading, isFetching, isError } = useGetAllReportsQuery(undefined, {
    selectFromResult: ({ data, isFetching, isError }) => ({
      isError,
      loading: isFetching,
      report: data?.find((c) => c.id == id),
    }),
  });
};

Manual/Optimistic Cache Updates

For updates that might have a long load time, or in the case that we don't want to entirely re-fecth a resource collection we can use manual caching to update the cache manually before (optimistically) or after the request completes.

Optimistically

Setting the order of elements as they're sent to the API to prevent any lag in reordering in the UI.

const topicsAPI = api
  .enhanceEndpoints({
    addTagTypes: ['admin/topics'],
  })
  .injectEndpoints({
    overrideExisting: false,
    endpoints: (builder) => ({
      reorderTopics: builder.mutation<Topic[], Topic[]>({
        query: (topics) => ({
          url: 'topics',
          method: 'PUT',
          body: { topics: decamelizeKeys(topics) },
        }),
        transformResponse: (response: { topics: Topic[] }) => response.topics,
        async onQueryStarted(topics, { dispatch, queryFulfilled }) {
          // we can prevent rubber banding in the UI by optimistically updating the cache
          const patchResult = dispatch(
            topicsAPI.util.updateQueryData('getAllTopics', undefined, (draft: Topic[]) => {
              topics.forEach((topic) => {
                const existing = draft.find((t) => t.id == topic.id);
                if (existing) {
                  existing.displayOrder = topic.displayOrder;
                }
              });
            }),
          );

          try {
            await queryFulfilled;
          } catch {
            patchResult.undo();
          }
        },
      }),

Manual

Adding a new item to the index collection as it comes back from the API.

      copyReport: builder.mutation<Report, Report>({
        query: (report) => ({
          url: `reports/${report.id}/copy`,
          method: 'POST',
        }),
        transformResponse: (response: { report: Report }) => response.report,
        async onQueryStarted(_arg, { dispatch, queryFulfilled }) {
          try {
            const { data } = await queryFulfilled;
            dispatch(
              reportsAPI.util.updateQueryData('getAllReports', undefined, (draft) => {
                draft.reports.unshift(data);
              }),
            );
          } catch (error) {
            console.error('Problem updating report', error);
          }
        },
      }),

Good Candidates for this

  • Reports API
    • Separate requests for index and self
    • League-based caching
  • Players API
    • Eliminates the current Redux cache
    • Eliminates multiple requests for the same player at varying component heights
  • Filter Params
    • League-based caching

Not-so-great Candidates

  • Groot/search APIs
    • Low overlap with search functions. However, abstracting the fetch logic and just not caching the result is probably still worth it.

Alternatives

  • createEntityAdapter
    • createEntityAdapter is more of a quick way to scaffold slices with CRUD logic. Its downfall is mainly that it frames everything from a CRUD perspective. Particularly when it comes to mutations or complex stores, this is less than ideal.
    • Also no caching
  • React/Tanstack Query
    • Haven't really looked into it, maybe a major flaw in all of this. We're already using RTK for what that's worth.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment