Angular Components and the Redux Store: Now You Can Have Your Cake and Eat it Too!

Written by Seth Davenport

Angular Components and the Redux Store: Now You Can Have Your Cake and Eat it Too!

Over the last year, two architectural ideas have risen to the surface of JavaScript web app development: Component-Oriented Architecture (COA) and Redux state management. Component-Oriented Architecture is one of the main tenets in both React and Angular: it encourages developers to break down the UI into a graph of self-contained, re-usable UI components. On the other hand, Redux is a functional-reactive approach to state management where the UI is at any time a derivation of a global, immutable store.

On the surface, these ideas seem to be at odds with each other when it comes to UI components that need to interact with state in order to function. COA seems to suggest that this state be encapsulated inside the component that uses it, whereas Redux's global store seems to imply exactly the opposite approach. This blog post explores this apparent tension and suggests an alternate approach using some new features of @angular-redux/store.

In this post, I assume some familiarity with Angular, Redux, and @angular-redux/store. For a quick primer take a look at the angular-redux quickstart tutorial.

Example

Let's take as an example a simple tab component:

first tab selected

second tab selected

We'd probably want to define a "tabset" UI component that selectively renders some content based on which tab has been chosen. We'd probably also want to be able to use this component a bit like this:

<demo-tabset  
  title="My TabSet Instance">
  <demo-tab title="The First Tab">
    Content to be shown when the first tab is clicked.
  </demo-tab>
  <demo-tab title="The Second Tab">
    Content to be shown when the second tab is clicked.
  </demo-tab>
</demo-tabset>  

A common way to implement something like this in Angular is to use projection and @ContentChildren:

// A simple wrapper that projects its children into a box that may
// or may not be visible.
@Component({
  selector: 'demo-tab',
  template: '<ng-content *ngIf="isVisible"></ng-content>',
})
export class TabComponent {  
  @Input() tabId: number;
  @Input() title: string;
  @Input() isVisible = false;
}

// The main tabset controls which of its projected tab children is
// currently visible.
@Component({
  selector: 'demo-tabset',
  template: `
    <div class="button-bar">
    <h5 class="title">{{ title }}</h5>
    <button
      *ngFor="let tab of tabs"
      [ngClass]="{ active: isActive(tab) }"
      (click)="updateChildVisibility(tab.tabId)">
      {{ tab.title }}
    </button>
  </div>
  <div class="content">
    <ng-content></ng-content>
  </div>
  `,
})
export class TabSetComponent implements AfterContentInit {  
  @Input() title;
  @ContentChildren(TabComponent) tabs: QueryList<TabComponent>;

  updateChildVisibility(activeTabId: number = 0): void {
    this.tabs.forEach((tab: TabComponent) =>
      tab.isVisible = (tab.tabId === activeTabId));
  }

  isActive(tab: TabComponent): boolean {
    // ...
  }

  ngAfterContentInit() {
    // Assign a local tabId to each TabComponent that's been
    // projected in.
    this.tabs.forEach((tab: TabComponent, idx: number) => tab.tabId = idx);
  }
}

In order to make this work, we need to keep track of the activeTabId for this tabset somewhere.
Where should that state live?

The Redux Store?

A naive use of Redux might keep the ID of the active tab in the global store, and then dispatch an action that updates activeTabId when the user clicks on a tab header:

interface IAppState = {  
  activeTabID?: string;
  // ...
}

const activeTabReducer = (state: string, action: PayloadAction) =>  
  action.type === 'ACTIVATE_TAB' ?
    action.payload :
    state;

const rootReducer = combineReducers({  
  activeTabId: activeTabReducer
});

// ...
ngRedux.configureStore(rootReducer, {});  

We can connect this Redux logic to our tabset as follows:

export class TabSetComponent implements AfterContentInit, OnDestroy {  
  // Shorthand for ngRedux.select('activeTabId') (the store
  // property is inferred from the decorated variable name).
  @select() readonly activeTabId$: Observable<number>;

  // The return value will be passed to ngRedux.dispatch for you.
  @dispatch() activateTab(tabId: number) {
    return { type: ACTIVATE_TAB, tabId });
  }

  isActive(tab: TabComponent, activeTabId: number = 0): boolean {
    return tab.tabId === activeTabId;
  }

  private subscription: Subscription;

  ngAfterContentInit() {
    this.tabs.forEach((tab: TabComponent, idx: number) => tab.tabId = idx);
    subscription = this.activeTabId$.subscribe(
      (activeTabId: number) => this.updateChildVisibility(activeTabId));
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

  // Rest of class as before.
}

We can then update the template to use the activeTabId$ Observable too:

<button  
  *ngFor="let tab of tabs"
  [ngClass]="{ active: isActive(tab, activeTabId$ | async) }"
  (click)="updateChildVisibility(tab.tabId)">
  {{ tab.title }}
</button>  

This is nice and simple, and gives us the main benefits of Redux: all the state is in the store so it's subject to Redux's powerful dev tools. In particular:

  • We can play the application backwards and forwards and the tabset will respond accordingly.
  • If someone finds a bug, they can export the action stream from Redux DevTools and we can load it into your own browser to play the app forward to the exact point of the failure.

However, we run into problems as soon as a web app wants to have multiple tabsets on the page at the same time. Because the state is global, is it also shared; selecting a tab in one instance of the tabset will change tabs in all other tabsets on the page as well!

In the Component?

In the non-Redux world, we'd just make the active tab a member variable on the tabset component:

export class TabSetComponent implements AfterContentInit {  
  private activeTabId = 0; // NEW

  isActive(tab: TabComponent): boolean {
    return activeTabId; // NEW
  }

  // Rest of class as in first section.
}

This works great: activeTabId is neatly encapsulated inside the instance of the tabset to which it belongs, so we can have many tabsets on the same page, and they all work independently.

However, we've lost many of the benefits of Redux. Because the active tabs are no longer kept in the global store, we can no longer time travel over them in Redux DevTools. If we have a weird layout bug that only happens when four tabsets on our page are in a specific state, we have to get the precise reproduction steps from QA and repeat the bug manually rather than importing an action script and replaying the app to the right spot automatically. Once we've been able to do that, going back to click-click-and-click can be pretty painful.

The Best of Both Worlds

We're faced with a conundrum: Redux's powerful debugging tools work best when as much of your application state is kept in the store as possible; state hidden inside component instances does not come under the purview of the development tools. This means that stateful components need to be debugged the old-fashioned way. On the other hand, the global nature of the Redux store makes it tricky to handle state for multiple instances of a component simultaneously.

What we want is a way to carve out a section of a store for each instance of a component? As of @angular-redux/store 6.5.0, this exists: it’s called @WithSubStore. Here's what it looks like:

@WithSubStore({
  basePathMethodName: 'getBasePath',
  localReducer: activeTabReducer,
})
@Component({
  // as in the Redux example.
})
export class TabSetComponent implements AfterContentInit, OnDestroy {  
  @Input() basePath: string[];
  @Input() title;
  @ContentChildren(TabComponent) tabs: QueryList<TabComponent>;
  @select() readonly activeTabId$: Observable<number>;

  @dispatch() activateTab(tabId: number) {
    return { type: ACTIVATE_TAB, tabId };
  }

  isActive(tab: TabComponent, activeTabId: number = 0) {
    return tab.tabId === activeTabId;
  }

  ngAfterContentInit() {
    this.tabs.forEach((tab: TabComponent, idx: number) => tab.tabId = idx);
    this.subscription = this.activeTabId$.subscribe(
      (activeTabId: number) => this.updateChildVisibility(activeTabId));
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

  private getBasePath() {
    return this.basePath;
  }

  private updateChildVisibility = (activeTabId: number = 0): void => {
    this.tabs.forEach((tab: TabComponent) =>
      tab.isVisible = (tab.tabId === activeTabId));
  }

  private subscription: Subscription;
}

This looks almost identical to the original Redux example above. However, we've added a couple of things. The first is an @Input() called basePath that allows us to declaratively tell NgRedux what part of the store belongs to this instance of the component. We can now update our application template as follows:

<demo-tabset [basePath]="['tabsets', 'tabset1']"  
  title="My TabSet Instance">
  <!-- ... tab data as before -->
</demo-tabset>

<demo-tabset [basePath]="['tabsets', 'tabset2']"  
  title="Another TabSet Instance on the Same Page">
    <!-- ... some more tab here -->
</demo-tabset>  

These two tabs will keep their state in the portions of the store denoted by the value of the basePath property, e.g.:

{
  tabsets: {
    tabset1: { activeTabId: 0 },
    tabset2: { activeTabId: 1 },
  }
}

The @WithSubStore decorator is affixed to the component itself and tells Angular-Redux to associate a slice of the store with a local reducer. The basePath is determined by calling the method specified in basePathMethodName; this is done so that each instance of the component can use a different slice of the store. Finally, localReducer is simply a reducer whose state manipulation is restricted to that same slice of the store.

When a component has the @WithSubStore decorator, any @select or @dispatch decorators are changed to operate on their slice of the store instead of on the global store. This ensures that selections will be rooted at the given base path, and dispatches will only be delivered to the localReducer for that component instance.

Conclusion

The tab example may not seem complicated enough to justify the introduction of another feature into an already complex framework, but it illustrates the pros and cons of keeping state in the Redux store versus keeping it in the component. Should you always use the @WithSubStore approach? It really boils down to how much granularity you need: in our experience, any component whose logic starts to look like a complex state machine can benefit greatly from this approach.

A lot of the power of Redux comes from the global nature of the store, and the way it forces you to think of your application as a single, large-state machine. However, there is also a lot of value in non-globality, because smaller, well-encapsulated components are easier to re-use, even across different applications. @WithSubStore helps you have both the transparency and debuggability of a global state machine with the re-useability of an encapsulated UI component.

Example Repo

You can see an example of the final tabset implementation in action at https://github.com/SethDavenport/tabset-demo.