Getting Started

Connecting a redux store to your custom-elements is just a matter of a few lines of code. We will use reduxjs/toolkit in this example, but it’s up to you how you want to create your store.

1. Create your store

Somewhere in your application you will be creating your store. Often this happens in your app.ts/app.js file:

import { configureStore, Store } from '@reduxjs/toolkit';

// This are your reducers and called however you want to call them
import todos from './reducer';

// create your store however you want
export const store = configureStore({
  reducer: todos
}) as Store;

Looks familiar? Nothing happening with this lbrary so far, let’s change that.

2. Register your store

Instead of importing your store and accessing it directly (which is also possible) you register your store under a scope. A scope is accessible under a Symbol. Customelement-store-binding also allows you to register a store under a default store.

import { configureStore, Store } from '@reduxjs/toolkit';
+ import { registerDefaultStore } from "customelement-store-binding";

// This are your reducers and called however you want to call them
import todos from './reducer';

// create your store however you want
export const store = configureStore({
  reducer: todos
}) as Store;

+ // Register the store as the default store
+ registerDefaultStore(store);

In case you have multiple stores or want to integrate with code which could already register a default store it’s best to define a custom scope:

import { configureStore, Store } from '@reduxjs/toolkit';
-import { registerDefaultStore } from "customelement-store-binding";
+import { registerStore } from "customelement-store-binding";
+import { storeScope } from "./symbols";

// This are your reducers and called however you want to call them
import todos from './reducer';

// create your store however you want
export const store = configureStore({
  reducer: todos
}) as Store;

- registerDefaultStore(store);
+ registerStore(storeScope, store);

with ./symbols.ts containing:

// for unique symbols (no outside deployment will ever clash with this)
export const storeScope = Symbol('myStore');
// for recretable symbols
export const constantScope = Symbol.for('myStore');

3. Connect the store to your web-component

Vanilla Custom Elements, lit-element, etc.

First step in using the store in your component is connecting it using the @useStore decorator.

import { useStore } from 'customelement-store-binding';

// that's all there is when you use a default store
@useStore()
class MyComponent extends HTMLElement {}

customElements.define('my-component', MyComponent);

Use @useStore({ scope: storeScope }) when using a custom scope

Additionally, it makes sense to define the renderFn option in the @useStore decorator, which will be called as soon as the store changes.

import { useStore } from 'customelement-store-binding';

// the render function to call on store changes
@useStore({ renderFn: el => el.render() })
class MyComponent extends HTMLElement {
  render() {
    // render all the things
    this.innerHTML = ``;
  }
}

customElements.define('my-component', MyComponent);

Lit-Element users: When using lit-element, you can use the LIT_ELEMENT renderFn provided. Check out the todo-lit-element example

Stencil

Stencil does not support class decorators as they actually derive a lot of information during compile time. For this reason, an alternative is to register the store using the useStoreFor function and passing the instance.

@Component({
  tag: 'my-component'
})
export class MyComponent {
  constructor() {
    // this does the same like the @useStore decorator
    useStoreFor(this);
  }
}

4. Bind selectors to your properties

Connecting the store isn’t much of a use when you don’t work with the stores content. For this the @bindSelector decorator can be used for fields.

import { useStore, bindSelector } from 'customelement-store-binding';

@useStore({ renderFn: el => el.render() })
class MyComponent extends HTMLElement {
  // get the content field from the store on updates
  @bindSelector(x => x.content)
  private content: string;

  render() {
    // simple content
    this.innerHTML = `Content is ${this.content}`;
  }
}

customElements.define('my-component', MyComponent);

For Stencil just add @State() above the @bindSelector and you’ll get immediate updates without using the renderFn. Check out the Stencil Example.

5. Dispatch actions

In a DOM enabled environment, actions are dispatched using CustomEvents. This has the advantage that your components do not need to know anything about your store and no specific API is required to dispatch events (you don’t need any decorators for this).

To wrap your action in a CustomEvent, the storeAction function can be used:

import { storeAction } from "customelement-store-binding";
import { addTodo } from "./store/actions";

  // ...
  private addTodo(title: string) {
    const action = addTodo({ title });
    // https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events
    this.dispatchEvent(storeAction(action));
  }

Stencil events

For stencil, the @dispatch(scope=DEFAULT) decorator can be used, which will assign an ActionDispatcher. This is required as stencil does neither extend HTMLElement (so you can’t do this.dispatchEvent) and the @Event annotation would have to be set explicitly to the internal event name of.

import { dispatchAction, ActionDispatcher } from "customelement-store-binding";

@dispatcher()
private dispatchAction: ActionDispatcher;

private finishTodo(todoId: string) {
  const action = finishTodo(todoId);
  this.dispatchAction(action);
}

Take a look at the stencil example if you want to see it in action.

6. Test it

Being able to test your components is quite important. There are multiple options how to test your component, depending on your prefered testing and coding style:

Option 1: Ignore store completely

As the decorators to nothing in case no store is bound, you could simply ignore a store being bound and set all state values manually. This is easy if your fields are public or have some sort of settor, of course.

Option 2: Use the MockStore

You can use the MockStore provided by this package and bind it as your default store before testing the component. As there is no reducer logic you will only be able to observe events being emitted - which can be enough in a lot of cases.

Just remember to register your store in beforeEach and clean up the registry in afterEach (or the equivalent in your testing framework):

import { MockStore, registerDefaultStore, resetStoreRegistry } from "customelement-store-binding";

describe("my component", () => {
  let store: MockStore<AppState>;

  beforeEach(() => {
    store = new MockStore<AppRootState>({ todos: [] });
    registerDefaultStore(store);
  });

  it("should test that something happens", (
    // ..update the state
    store.setState({...store.getState(), content: 'test' });
    // ..listen for actions
    const eventSpy = jasmine.createSpy('actionSpy');
    document.addEventListener("dispatchStoreAction", eventSpy, { once: true });
    // ...
    expect(eventSpy).toHaveBeenCalledWith(...);
  ))

  afterEAch(() => {
    // this removes the effect of all register*Store functions
    resetStoreRegistry();
  });

});

A full example of this testing style can be found in the lit-element tests

Option 3: Use an actual store with reducers

The last option is the one nearest to how your component will be used (which is good), but also the one that departs the most from actual unit testing to integration tests (which can be bad, but doesn’t need to be): Binding an actual store with reducers.

This is actually the same as Option 2, but instead of instantiating MockStore you will create the actual store:

beforeEach(() => {
  // use the actual store
  registerDefaultStore(configureStore({ reducer: todos }));
});

You find a full example using this approach in the lit-element-todo example and in the stencil todo example.

For stencil, be aware that you need to bind the store after calling newSpecPage, but before awaiting it’s result, otherwise you bind your store to the wrong document. Check out the example to see what that means.