- 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
andcreateSlice
.
TLDR: Abstraction of fetching logic + caching
- Manages the Redux store, taking things returned from the API and storing them in the API slice.
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.
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
- 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
- Managing API/Redux integration in turn removes data from Redux slices, focusing our interactions with Redux solely on managing application state.
- Manages integration with React lifecycle (See: React docs)
- 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.
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 };
};
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));
};
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"]
}
})
);
});
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),
}),
});
};
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);
}
},
}),
- 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
- Groot/search APIs
- Low overlap with search functions. However, abstracting the fetch logic and just not caching the result is probably still worth it.
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.