DEV Community

Cover image for How I got selectors in Redux Devtools
Mike Pearson for This is Learning

Posted on

How I got selectors in Redux Devtools

YouTube

Redux Devtools is nice for inspecting state.

But sometimes it would be nice to be able to inspect derived state too.

It's not a good practice to put derived state in the store. But my solution doesn't put it in the store; it puts it in Redux Devtools only, using the stateSanitizer option.

It started with a different problem

Selectors have always bugged me. The syntax is so long. I mean, look at this:

const selector = createSelector(
  selectThing1,
  selectThing2,
  (thing1, thing2) => doCalculation(thing1, thing2),
);
Enter fullscreen mode Exit fullscreen mode

If we didn't need that memoized, in React we could just have

const thing3 = doCalculation(thing1, thing2);
Enter fullscreen mode Exit fullscreen mode

Why should a performance optimization impact our code so much?

So, I wanted to redesign syntax for selectors from the ground up: If I could magically have the most ideal syntax for creating selectors, what would it look like?

Selector syntax file diff

Left: Reselect. Right: What I ended up with
.

First, I thought it would be nice if I could just define the function itself, and have what I'm accessing automatically detected and memoized for me. But how do we detect when code is accessing certain state, and attach extra behavior to it? Well that would be a JavaScript Proxy. So could I define my selector like this?

const selectors = new Proxy(...);
// ...
selectors.getThing3 = selectors.getThing2 + selectors.getThing1;
Enter fullscreen mode Exit fullscreen mode

What about TypeScript? And how do we use this selector in future selectors in a reliable way?

I ended up deciding on a builder pattern, with a function that could be called again and again, sort of like this:

const selectors = buildSelectors<string>({
  reverse: state => state.split('').reverse().join(''),
})({
  isPalendrome: s => s.reverse === s.state,
})({
  // etc...
})() // End
Enter fullscreen mode Exit fullscreen mode

selectors would then be an object with everything in it, and typed correctly.

This is similar to how Redux Toolkit and NgRx/Entity's state adapters have one big object with a lot of state management utilities all together. The exact syntax I ended up was inspired by them:

const adapter = buildAdapter<State>()()({ // Sorry
  reverse: s => s.state.split('').reverse().join(''),
})({
  isPalendrome: s => s.reverse === s.state,
})({
  // etc...
})() // End
Enter fullscreen mode Exit fullscreen mode

So, why s? Well, should the object (s) passed into each selector function be named selectors, state or selectorState? In reality it's just a proxy, so none of these really make sense. So, the convention is s, since it's short and the only letter all the possible meanings share.

Also, since we're treating the selectors as if they were just state, I named them nouns. It's much less awkward than verbs like getIsPalendrom: s => s.getReverse === s.getState.

So far, this is nice.

But there's another issue I have with standard Redux/NgRx: Reusability. With standard Redux/NgRx, if we want to reuse selectors for different instances of state, we can't just call createSelector; we have to create selector creators. Here's what that looks like:

import { createSelector } from 'reselect'; // or whatever

// Need a function that returns the selector in order to be
// reusable and independently memoized:
const getSelectReverse = (selectState: (state: any) => string) =>
  createSelector(selectState, state => state.split('').reverse().join(''));

const getSelectIsPalendrome = (selectState: (state: any) => string) =>
  createSelector(
    selectState,
    getSelectReverse(selectState),
    (state, reverse) => state === reverse
  );

// ...
// Before using for some specific state
const selectReverse = getSelectReverse(selectSpecificState);
const selectIsPalendrome = getSelectIsPalendrome(selectSpecificState);
Enter fullscreen mode Exit fullscreen mode

Wow, that sucks.

Let's look again at the syntax I think is ideal:

import { buildAdapter } from '@state-adapt/core';

const stringAdapter = buildAdapter<string>()()({
  reverse: s => s.state.split('').reverse().join(''),
})({
  isPalendrome: s => s.reverse === s.state,
})();
Enter fullscreen mode Exit fullscreen mode

That's better.

But is it possible?

The hard part: implementation

I implemented this syntax in my state management library, StateAdapt. StateAdapt is a state management library based on the state adapter pattern introduced by NgRx/Entity's entityAdapter.

The implementation is fairly straight-forward. For each new selector in each selector block, create a new function that will

  • Call the developer-defined function but pass in a proxy instead of state
  • In the proxy get handler, use the name of the selector to call each actual selector function, passing in state
  • Cache the results from each "input" selector
  • Before each run, check if there are any changes with the input selectors and their cached values

Here's a cool benefit from this: Since selectors are deterministic, if only 1 selector was accessed last time and it returns the same value this time, we don't need to call the function again. For example, if we had a selector defined like this:

  something: s => s.thing1 || s.thing2 || s.thing3,
Enter fullscreen mode Exit fullscreen mode

and s.thing1 always returned something truthy, then it would never matter what thing2 or thing3 would return, so we never call them. With traditional memoized selectors, all input selectors have to be called in advance. This could cause unnecessary calls to thing2 and thing3, and if those ever returned anything different, the main selector function would need to run again as well; they're unnecessary work themselves, but they can also trigger unnecessary work in our new selector. With proxies, we can avoid all of that!

Isn't that cool?

The problem

But there's actually a big problem with this.

Each of these selector functions is getting created in the same context. That means when our proxy caches the input selector values, it caches them for every use of that selector. So if we try to use the selector on two entirely different states, memoization would only occur when the states happen to be the same, which may never happen.

I really struggled to find a solution to this, because I loved the syntax I had, and everything I thought of for the first whole day required changing the API I created.

The first possibility was to define each selector block in a function:

const stringAdapter = buildAdapter<string>()()(() => ({
  reverse: s => s.state.split('').reverse().join(''),
}))(() => ({
  isPalendrome: s => s.reverse === s.state,
}))();
Enter fullscreen mode Exit fullscreen mode

This would allow me to define the blocks lazily with a context and cache object dedicated to each specific instance of state.

But that syntax collided with other functionality I already implemented, so I quickly moved on to something even more annoying:

const stringAdapter = () => 
  buildAdapter<string>()()({
    reverse: s => s.state.split('').reverse().join(''),
  })({
    isPalendrome: s => s.reverse === s.state,
  })();
Enter fullscreen mode Exit fullscreen mode

Now I could build the entire adapter lazily and have a cache object dedicated to each specific instance of state.

But what is this thing? A state adapter creator? Do we have to get annoying with the names? And Prettier insisted on defining it with the last line indented, so that would be annoying for people using VSCode like me when they press Enter and VSCode automatically indents the cursor 1 level. I hate that.

Next I tried something else: Have each selector be a selector creator, similar to traditional memoized selectors:

const stringAdapter = buildAdapter<string>()()({
  reverse: () => s => s.state.split('').reverse().join(''),
})({
  isPalendrome: () => s => s.reverse === s.state,
})();
Enter fullscreen mode Exit fullscreen mode

I was pretty happy with this, but I really, really hate superfluous syntax, and it just hurt me to force everyone using StateAdapt (all 10 of them) to write an extra () => thousands of times. Not to mention a breaking change in StateAdapt's API.

But this got me closer to what ended up working.

When I want to call a selector for a specific piece of state, I pass in a cache object as the 2nd argument. There will still only be 1 selector function used for all instances of state, but it uses the cache object passed to it, and passes it along to the selector functions above.

This means I need a cache object dedicated to each piece of state. Then when I use a state adapter, I just define a new object with all the same selectors, but with the cache object passed in the 2nd argument, so the new selectors just need to pass in state and the correct cache object is implicitly used.

But wait... Now we have a big cache object with all the return values of all selectors used for a piece of state... That's pretty convenient!

Selectors in Redux Devtools!

So here is how we can now get selectors into Redux Devtools:

  1. Assemble each reducer's selector cache into one giant, global selector cache
  2. Create a serializer that converts the cache into a simple object that will be easy to read in Redux Devtools
  3. Attach the global cache object to the window object
  4. Create a custom state sanitizer for Redux Devtools that accesses the global cache object, serializes it, and adds it as another property onto state

There's one caveat: Since the selectors are evaluated lazily, when they show up in Redux Devtools, the cached values will be based on the previous state. So, I named them _prevSelectors in StateAdapt.

Using this with NgRx or Redux

You can import the stateSanitizer from @state-adapt/core and use this with Redux or NgRx today:

import { stateSanitizer } from '@state-adapt/core';

// Redux
const store = createStore(
  rootReducer,
  window.__REDUX_DEVTOOLS_EXTENSION__?.({ stateSanitizer }),
);
// Or however you pass in the stateSanitizer with your flavor of Redux

// NgRx
    StoreDevtoolsModule.instrument({
      // ...
      stateSanitizer,
    }),
Enter fullscreen mode Exit fullscreen mode

Whatever selectors you define using buildAdapter will be possible to have shown in Redux Devtools. But there's an extra function we need to call to connect the selectors to global state, called mapToSelectorsWithCache:

import { buildAdapter, createSelectorsCache, mapToSelectorsWithCache } from '@state-adapt/core';

// ...

const stateAdapter = buildAdapter<State>()()({
  // ...
})();

// ...

// Each state slice will be memoized independently:

const getState1 = (state: any) => state.childState1 as State;
export const cache1 = createSelectorsCache();
const state1Selectors = mapToSelectorsWithCache(
  stateAdapter.selectors,
  getState1,
  cache1,
);

const getState2 = (state: any) => state.childState2 as State;
export const cache2 = createSelectorsCache();
const state2Selectors = mapToSelectorsWithCache(
  stateAdapter.selectors,
  getState2,
  cache2,
);
Enter fullscreen mode Exit fullscreen mode

This is easier in StateAdapt, by the way :)

Now, just like you have a root reducer in Redux and NgRx, you will have a root selectors cache. But you import this from StateAdapt and attach your feature caches as children (it's a recursive structure):

import { createSelectorsCache, globalSelectorsCacheKey } from '@state-adapt/core';
import { cache1, reducer1, cache2, reducer2 } from './child-feature/reducer';

const cacheChildren = (window as any)[globalSelectorsCacheKey]?.__children;
cacheChildren.childState1 = cache1;
cacheChildren.childState2 = cache2;

export const reducer = combineReducers({
  childState1: reducer1,
  childState2: reducer2,
});
Enter fullscreen mode Exit fullscreen mode

And that's it!

Here's a working demo remo you can check out: Cart Demo

Joined Selectors

All of this works if you have selectors that only select from a single slice of state. What if you want to combine selectors across multiple slices of state?

Well, in Redux and NgRx you can always just use a regular selector. It won't use global cache obviously, but it will function as expected anyway:

const selectAllArePalendromes = createSelector(
  state1Selectors.isPalendrome,
  state2Selectors.isPalendrome,
  (...palendromes) => palendromes.every(p => p),
);
Enter fullscreen mode Exit fullscreen mode

In StateAdapt, each reducer is called a "smart store", and you can combine state from multiple smart stores like this (example from Angular):

store1 = adapt(['string1', 'racecar'], adapter);
store2 = adapt(['string2', 'racecar'], adapter);

allArePalendromes$ = joinStores({
  string1: this.store1,
  string2: this.store2,
})({
  allArePalendromes: s => s.string1IsPalendrome && s.string2IsPalendrome,
})().allArePalendromes$;
Enter fullscreen mode Exit fullscreen mode

This syntax isn't as minimal as it could be, but it mirrors syntax for a function called joinAdapters so it could be easy to restructure state if necessary. Minimalism shouldn't always the top concern.

Anyway, with some fun code I was able to get these to show up in Redux Devtools too. They show up as children of both input stores, unfortunately using the cache ID as the key instead of a meaningful string. But at least they're there!

Joined Selectors

Conclusion

I didn't know I was going to end up putting selectors in Redux Devtools when I started trying to figure out how to make selectors in adapters reusable and independently cached. But I'm glad I did! Selectors contain a lot of important business logic, and this is one more instance where we can potentially save a lot of time by not having to set breakpoints in code whenever a selector outputs something unexpected.

I hope you try out buildAdapter and give me some feedback. StateAdapt is getting very close to a 1.0 release. While it has been used in dozens of projects now, and it has automated tests covering every bug found to date, there is still a chance of something missing that will need to be addressed before 1.0. But it shouldn't take longer than a couple of weeks.

And if you like buildAdapter, you might as well try using the rest of StateAdapt on a feature. Once you have an adapter, plugging it into a smart store is extremely easy:

adapter = buildAdapter<State>(...);
store = adapt(['statePath', initialState], this.adapter);
state$ = store.state$; // Observable of store's state
Enter fullscreen mode Exit fullscreen mode

So give it a try and give the repo a star if you like it!

Let me know how it goes!

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.