22

I recently started using redux-toolkit and started writing my reducers using the createSlice following their docs.

One reducer, let's call it reducerA, imports customAsyncFunction to handle its callback, this function is created through createAsyncThunk which in turn reads the RootState when it calls thunkApi.getState(), the problem now is that when RootReducer is imported, reducerA will be imported generating a circular reference.

Basically: RootReducer -> reducerA -> actions -> RootReducer -> ...

Below I attempt to simplify the problem.

// actions.ts file
import { RootState } from "./RootReducer";

export const customAsyncAction = createAsyncAction("myaction", async (_, thunkApi) =>
  const state = thinkApi.getState() as RootState;
  ...
);


// reducerA.ts file
import { customAsyncAction } from "./actions";

const slice = createSlice({
  ...
  extraReducers: {
    [customAsyncAction.fulfilled.toString()]: ... // handles fulfilled action
  }
});

export default slice.reducer;



// RootReducer.ts file
import reducerA from "./reducerA"
import reducerB from "./reducerB"

const reducers = combineReducers({
  reducerA,
  reducerB
});

export type RootState = ReturnType<typeof reducers>; // complains about circular reference

In this section of the documentation it's mentioned the likelihood of this happening and there are vague suggestions of splitting the code in files. However from all my attempts I can't seem to find a way to fix this problem.

Rigotti
  • 2,766
  • 23
  • 28

3 Answers3

30

TS thinks the import is a module, and don't know it's a type and it's totally fine to import it. In order to tell it's a type import Try importing it as a type:

import type { RootState } from "./RootReducer";

Note the type keyword after import . This way ts/babel/eslint know you import a type, not a module and will exclude it from dependency map, thus resolving the issue.

Chris Panayotoff
  • 1,744
  • 21
  • 24
8

Type-only circular references are fine. The TS compiler will resolve those at compile time. In particular, it's normal to have a slice file export its reducer, import the reducer into the store setup, define the RootState type based on that slice, and then re-import the RootState type back into a slice file.

Circular imports are only a potential issue when runtime behavior is involved, such as two slices depending on each other's actions.

Unfortunately, the ESLint rule for catching circular dependencies can't tell that what's being imported is just a type, as far as I know.

markerikson
  • 63,178
  • 10
  • 141
  • 157
  • Yes, apparently this is no problem. However now RootState does not get the correct auto completion for me and as far I can see the problem is sent from typescript and not from eslint – Rigotti Sep 17 '20 at 07:26
  • Came there when trying to setup pretty incremental Bazel setup where every Redux Feature is in separate Bazel package/module. As Bazel requires to list all the dependencies i should declare that Root depends on Slice and the Slice depends on Root which is not allowed in Bazel. If I would build the whole app in the single build, I would not have this problem. – Dzintars Sep 28 '20 at 20:35
  • 2
    It might not be a runtime issue but it still is a design issue that hinders maintainability – ArnauOrriols Feb 12 '21 at 09:52
  • 1
    No. It's not a design issue, and in fact it's a recommended pattern. As I said, a typical example is that a slice file exports its own slice reducer, which is then used to define the total `RootState` type (either explicitly or by inferring from the root reducer). The slice file then imports the `RootState` type for additional use, such as declaring `const selectThing = (state: RootState) => state.some.value`. This works as expected at compile time, and does not affect any runtime behavior. – markerikson Feb 12 '21 at 16:55
4

After reading the following docs section on how to use createAsyncThunk with TypeScript, I've changed the implementation of extraReducers to use the builder pattern instead of passing a key value object and the error vanished.

// before
const slice = createSlice({
  ...
  extraReducers: {
    [customAsyncAction.fulfilled.toString()]: ... // handles fulfilled action
  }
});

// after
const slice = createSlice({
  ...
  extraReducers: builder => {
    builder.addCase(customAsyncAction.fulfilled, (state, action) => ...)
  }
});

I must admit I can't pinpoint exactly why under the first condition it doesn't works, while in the second one it does.

Rigotti
  • 2,766
  • 23
  • 28