Cousins playing nicely: Experimenting with NgRx Store and RTK Query

brandontroberts

Brandon Roberts

Posted on April 13, 2021

Cousins playing nicely: Experimenting with NgRx Store and RTK Query

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
Enter fullscreen mode Exit fullscreen mode

OR

yarn add @reduxjs/toolkit @rtk-incubator/rtk-query react react-dom react-redux
Enter fullscreen mode Exit fullscreen mode

For additional type information, I installed the @types/react-redux package.

npm install @types/react-redux --only=dev
Enter fullscreen mode Exit fullscreen mode

OR

yarn add @types/react-redux --dev
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']
    }),
  }),
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 || []
);
Enter fullscreen mode Exit fullscreen mode

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),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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)]
  };
}
Enter fullscreen mode Exit fullscreen mode

And a provider is needed to register the products feature config.

export function provideProductsFeatureConfig() {
  return [
    {
      provide: PRODUCTS_FEATURE_CONFIG_TOKEN,
      deps: [ThunkService],
      useFactory: getProductsFeatureConfig,
    }
  ];
}
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

NgRx RTK Query Demo

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

💖 💪 🙅 🚩
brandontroberts
Brandon Roberts

Posted on April 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related