Brandon Roberts
Posted on April 13, 2021
Redux provides state management that has been widely used across many different web ecosystems for a long time. NgRx provides a more opinionated, batteries-included framework for managing state and side effects in the Angular ecosystem based on the Redux pattern. Redux Toolkit provides users of Redux the same batteries-included approach with conveniences for setting up state management and side effects. The Redux Toolkit (RTK) team has recently released RTK Query, described as "an advanced data fetching and caching tool, designed to simplify common cases for loading data in a web application", built on top of Redux Toolkit and Redux internally. When I first read the documentation for RTK Query, it immediately piqued my interest in a few ways:
- Simple and Effective
- Built on Redux
- Developer experience focused and built with TypeScript
RTK Query is also framework-agnostic, which means it can integrate into any framework. NgRx is a state management framework for Angular based on Redux. So, they should work together, right? This post examines how well they can work together and what I learned while experimenting.
Setting Up Dependencies
To use RTK Query in an Angular application, some React packages also have to be installed. I installed the common React and Redux toolkit packages.
npm install @reduxjs/toolkit @rtk-incubator/rtk-query react react-dom react-redux
OR
yarn add @reduxjs/toolkit @rtk-incubator/rtk-query react react-dom react-redux
For additional type information, I installed the @types/react-redux
package.
npm install @types/react-redux --only=dev
OR
yarn add @types/react-redux --dev
Setting up Listeners
RTK Query enables listeners to refetch data on focus, and when reconnecting after going offline. This involves using the setupListeners
function and passing the Store.dispatch
method to it.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { Store, StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { setupListeners } from '@rtk-incubator/rtk-query';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { environment } from '../environments/environment';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule,
StoreModule.forRoot({}),
EffectsModule.forRoot([]),
StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production })
],
bootstrap: [AppComponent]
})
export class AppModule {
constructor(store: Store) {
setupListeners(store.dispatch.bind(store));
}
}
Note: Throughout this experiment, I had to use the .bind
syntax in order to not lose the scope for the Store instance.
Generating a Products Route
For the feature page, I generated a products module that is lazy-loaded, along with the products component.
ng g module products --route products --module app
Setting up an API Service
RTK Query creates "API Services" to handle all the data-fetching and state manipulation for the slice of state. These different methods are defined as endpoints
through the generated service. I defined a Product
interface, and a productsApi
using its createApi
function.
import { createSelector } from '@reduxjs/toolkit';
import { createApi, fetchBaseQuery } from '@rtk-incubator/rtk-query';
export interface Product {
id: number;
name: string;
}
export const productsApi = createApi({
reducerPath: 'products',
entityTypes: ['Products'],
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000/' }),
endpoints: (builder) => ({
getProducts: builder.query<Product[], string>({
query: () => 'products',
provides: (result) => result.map(({ id }) => ({ type: 'Products', id }))
}),
addProduct: builder.mutation<Product, Partial<Product>>({
query: (body) => ({
url: `products`,
method: 'POST',
body,
}),
invalidates: ['Products']
}),
}),
});
Note: I used the json-server
package to setup a quick API for http://localhost:3000/products
used in this example.
The endpoints use a builder pattern for providing the path to the API URL, querying the API, providing mutations, and cache invalidation for the Products
. In React, there are provided hooks to abstract away the usage of the endpoints
that include actions, and selectors but they are provided for usage outside of React.
The productsApi
object also contains additional properties including the feature key, named reducerPath
, and reducer
function itself.
Defining Actions
The productsApi
contains defined methods on the endPoints
property that are essentially action creators.
export const loadProducts = () => productsApi.endpoints.getProducts.initiate('');
export const addProduct = (product: Product) => productsApi.endpoints.addProduct.initiate(product);
The initiate
method returns what looks like traditional action creators, but they are actually "Thunks" used in the RTK Query middleware.
Defining Selectors
Selectors are also defined using the generated endpoints
. The getProducts
returns the entire state itself. This integrates seamlessly with the createSelector
function in NgRx Store to build another selector for the products.
export const selectProductsState = productsApi.endpoints.getProducts.select('');
export const selectAllProducts = createSelector(
selectProductsState,
state => state.data || []
);
RTK Query also provides selectors for getting additional information such as the isLoading
, isError
, and isSuccess
statuses which can be combined in the same way.
Handling Thunks
The first hurdle I ran into is where NgRx Store and Redux start to diverge. A Redux Store provides a synchronous way to get a snapshot of the state, and handling of Redux Thunks. From the Redux Thunk documentation
"Thunks are the recommended middleware for basic Redux side effects logic, including complex synchronous logic that needs access to the store, and simple async logic like AJAX requests."
In order to replicate this API, I created a ThunkService
that provides the same API necessary to use Redux Middleware.
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { MiddlewareAPI, ThunkAction } from '@reduxjs/toolkit';
import { SubscriptionLike } from 'rxjs';
import { take } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class ThunkService {
constructor(private store: Store) {}
getState() {
let state: object;
this.store.pipe(take(1)).subscribe((res) => {
state = res;
});
return state;
}
dispatch(thunkAction: ThunkAction<any, any, any, any>): SubscriptionLike {
return thunkAction(
(thunk: ThunkAction<any, any, any, any>) =>
thunk(this.store.dispatch.bind(this.store), this.getState.bind(this), undefined),
this.getState.bind(this),
undefined
);
}
runThunk(thunk: ThunkAction<any, any, any, any>) {
return thunk(
this.store.dispatch.bind(this.store),
this.getState.bind(this),
undefined
);
}
middlewareApi(): MiddlewareAPI {
return {
dispatch: this.runThunk.bind(this),
getState: this.getState.bind(this),
};
}
}
The getState
method provides a snapshot of the store, and the dispatch
method allows for asynchronous dispatching of actions with the provided "thunks".
Connecting the Middleware
In order to use the Redux middleware, I needed to connect it to the Store. I ended up using a meta-reducer. Meta-reducers by default are just methods that receive a reducer function, and return another reducer function that acts as "middleware" in the NgRx Store. They also don't have access to Dependency Injection unless registered using and InjectionToken
, so I created an InjectionToken
for the Products Feature State config.
export const PRODUCTS_FEATURE_CONFIG_TOKEN = new InjectionToken<StoreConfig<any>>('Products Feature Config');
Then I created the factory function to use the ThunkService
, and a meta-reducer to use the middleware provided by the productsApi
. When registered, this meta-reducer will only be applied to the products
slice of state.
export function productsMiddleware(dispatcher: ThunkService) {
return function(reducer: ActionReducer<any>): ActionReducer<any> {
return function(state, action) {
const next = productsApi.middleware(dispatcher.middlewareApi());
const nextState = next(action => reducer(state, action));
return nextState(action);
};
}
}
A factory function is needed to return the defined meta-reducers for the productsMiddleware
function.
export function getProductsFeatureConfig(thunkService: ThunkService): StoreConfig<any> {
return {
metaReducers: [productsMiddleware(thunkService)]
};
}
And a provider is needed to register the products feature config.
export function provideProductsFeatureConfig() {
return [
{
provide: PRODUCTS_FEATURE_CONFIG_TOKEN,
deps: [ThunkService],
useFactory: getProductsFeatureConfig,
}
];
}
Registering the feature state
The feature state is setup, and is registered in the ProductsModule
using familiar syntax with StoreModule.forFeature()
.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { StoreModule } from '@ngrx/store';
import { ProductsRoutingModule } from './products-routing.module';
import { ProductsComponent } from './products.component';
import { PRODUCTS_FEATURE_CONFIG_TOKEN, productsApi, provideProductsFeatureConfig } from '../services/products';
@NgModule({
declarations: [ProductsComponent],
imports: [
CommonModule,
ReactiveFormsModule,
ProductsRoutingModule,
StoreModule.forFeature(productsApi.reducerPath, productsApi.reducer, PRODUCTS_FEATURE_CONFIG_TOKEN)
],
providers: [
provideProductsFeatureConfig()
],
})
export class ProductsModule { }
The productsApi.reducerPath
is products
as defined in the API Service, the reducer function is fully generated, the PRODUCTS_FEATURE_CONFIG_TOKEN
token and provideProductsFeatureConfig
method are used to register the provider to access dependency injection when registering the meta-reducer.
Adding a Product
To add a Product
, I quickly put together a small form component using Reactive Forms to output the Product after the form is submitted.
import { Component, OnInit, EventEmitter, Output } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'app-product-form',
template: `
<form [formGroup]="productForm" (ngSubmit)="addProduct()">
<div>
Name: <input formControlName="name" type="text">
</div>
<div>
Description: <input formControlName="description" type="text">
</div>
<button type="submit">Add Product</button>
</form>
`
})
export class ProductFormComponent implements OnInit {
productForm = new FormGroup({
name: new FormControl(''),
description: new FormControl('')
});
@Output() submitted = new EventEmitter();
constructor() { }
ngOnInit(): void {
}
addProduct() {
this.submitted.emit(this.productForm.value);
}
}
I registered the ProductsFormComponent
in order to use it in the ProductsComponent
template.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { StoreModule } from '@ngrx/store';
import { ProductsRoutingModule } from './products-routing.module';
import { ProductsComponent } from './products.component';
import { ProductFormComponent } from './product-form.component';
import { PRODUCTS_FEATURE_CONFIG_TOKEN, productsApi, provideProductsFeatureConfig } from '../services/products';
@NgModule({
declarations: [ProductsComponent, ProductFormComponent],
imports: [
CommonModule,
ReactiveFormsModule,
ProductsRoutingModule,
StoreModule.forFeature(productsApi.reducerPath, productsApi.reducer, PRODUCTS_FEATURE_CONFIG_TOKEN)
],
providers: [
provideProductsFeatureConfig()
],
})
export class ProductsModule { }
Fetching and Adding Products
The state is all wired up, so then I used the state in the ProductsComponent
. I used the app-product-form
, and list out the products that are selected from the Store. I also injected the ThunkService
as a dispatcher for the Action Thunks to be used with the RTK Query middleware.
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { SubscriptionLike } from 'rxjs';
import * as uuid from 'uuid';
import { Product } from '../models/product';
import { addProduct, loadProducts, selectAllProducts } from '../services/products';
import { ThunkService } from '../services/thunk.service';
@Component({
selector: 'app-products',
templateUrl: './products.component.html',
styleUrls: ['./products.component.css']
})
export class ProductsComponent implements OnInit, OnDestroy {
products$ = this.store.select(selectAllProducts);
loadSub$: SubscriptionLike;
addSub$: SubscriptionLike;
constructor(private store: Store, private dispatcher: ThunkService) { }
ngOnInit(): void {
this.loadSub$ = this.dispatcher.dispatch(loadProducts());
}
onProductAdded(product: Product) {
this.addSub$ = this.dispatcher.dispatch(addProduct({id: uuid.v4(), ...product}));
}
ngOnDestroy() {
this.loadSub$.unsubscribe();
this.addSub$.unsubscribe();
}
}
The products
are selected using the selectAllProducts
selector, and the dispatcher
dispatches the Action Thunks provided by RTK Query. If you hadn't noticed, I am not using the HttpClientModule
or NgRx Effects to do any data-fetching or side effects. All this behavior is handled through RTK Query internally. And I still have access to see all the changes in the Redux Devtools.
First Impressions
- RTK Query integrates well with NgRx Store and could potentially be integrated more closely together.
- It was interesting that off-the-shelf Redux Middleware can be connected through a meta-reducer with a little bit of effort. This opens up the possibility to use other Redux Middleware with NgRx Store.
To see the full example, check out the GitHub Repo
What do you think? Will you be using RTK Query in your Angular applications? Leave a comment below, or reach out to me on Twitter at @brandontroberts.
Note: While I was going through my experiment, Saul Moro also created a hook-style library for Angular, NgRx, and RTK Query. Check out the repo at:
https://github.com/SaulMoro/ngrx-rtk-query
Posted on April 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.