Skip to content

[RFC] combineSlices helper with injectable slice/reducer support and selector wrapping #2776

@phryneas

Description

@phryneas

I'm just writing this down so I get my head emptied out and see if what I'm thinking makes any sense

reducer.ts

import { combineSlices } from '@reduxjs/toolkit'

import { sliceA } from 'fileA'
import { sliceB } from 'fileB'
import { lazySliceC } from 'fileC'
import type { lazySliceD } from 'fileD'

import { anotherReducer } from 'somewhere'

export interface LazyLoadedSlices {}

export const rootReducer = combineSlices(sliceA, sliceB, {
  another: anotherReducer,
}).withLazyLoadedSlices<LazyLoadedSlices>()
/*
 results in a return type of
 {
    [sliceA.name]: SliceAState,
    [sliceB.name]: SliceBState,
    another: AnotherState,
    [lazySliceC.name]?: SliceCState, // see fileC.ts to understand why this appears here
    [lazySliceD.name]?: SliceDState, // see fileD.ts to understand why this appears here
 }
 */

the "naive" approach

fileC.ts

import { rootReducer, RootState } from './reducer'
import { createSlice } from '@reduxjs/toolkit'

interface SliceCState {
  foo: string
}

declare module './reducer' {
  export interface LazyLoadedSlices {
    [lazySliceC.name]: SliceCState
  }
}

export const lazySliceC = createSlice({
  /* ... */
})
/**
 * Synchronously call `injectSlice` in file.
 * This will add `lazySliceC.reducer` to `rootReducer`, but **not** trigger an action.
 * `state.lazySliceC` will stay `undefined` until the next action is dispatched.
 */
rootReducer.injectSlice(lazySliceC)


// this will still error - `lazySliceC` is optional here
const naiveSelectFoo = (state: RootState) => state.lazySliceC.foo

/**
 * `injectSlice` would not inject the slice again if it is referentially equal, so it could be called 
 * in multiple files to get a `rootReducer` with types that are aware that `lazySliceC` has already 
 * been injected
 */
const selectFoo = rootReducer.injectSlice(lazySliceC).selector((state) => {
    /**
     * `lazySlice` is guaranteed to not be `undefined` here.
     * we wrap the selector and if it is called before another action has been dispatched
     * (meaning `state.lazySlice` is still `undefined`), the selector will not be called with 
     * the real `state`, but with a modified copy (or a Proxy or whatever we can do here to keep
     * it as stable as possible)
     */
  return state.lazySlice.foo
})

the next step (maybe in a later release?)

"integrated" approach - adding a selectors field to createSlice
fileD.ts

import { rootReducer } from './reducer'
import { createSlice, WithSlice } from '@reduxjs/toolkit'

interface SliceDState {
  bar: string
}

declare module './reducer' {
    // new helper `WithSlice`
  export interface LazyLoadedSlices extends WithSlice<typeof lazySliceD> {}
}

export const _lazySliceD = createSlice({
  /* ... */
  selectors: {
    uppercaseBar: sliceState => sliceState.bar.uppercase()
  }
})

// this will still error, as `state.lazySliceD` is still optional at this point
_lazySliceD.selectors.uppercaseBar(state)

// this will internally just call `rootReducer.injectSlice(_lazySliceD)
const lazySliceD = _lazySliceD.injectInto(rootReducer) 
// this will now work even if no action has been dispatched yet, as `selectors` now have been wrapped
// in a similar approach to `rootReducer.withSlice(lazySliceC).selector`
lazySliceD.selectors.uppercaseBar(state)

general interface

declare function combineSlices(...slices: Slice | Record<string, reducer>): Reducer<CombinedState> & {
    withLazyLoadedSlices<LazyLoadedSlices>() // returns same object with enhanced types - those slice states are now optional
    injectSlice(slice: Slice) // returns same object with enhanced types - that slice's state is now non-optional
    selector(selectorFn)
}

// additions to a `slice`:
injectInto(inectableReducer)

// additions to slice options:
selectors: Record<string, SelectorFn>
// this might have room for improvement - do we allow for selectors that also take other state parts into account here or do those need to be created outside? How do we allow for memoized selectors?

Open questions:

  • do we have any circular type problems anywhere here?
  • will this work nicely with nested combineSlice calls?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions