Skip to content

@withease/redux

Minimalistic package to allow simpler migration from Redux to Effector. Also, can handle any other use case, where one needs to communicate with Redux Store from Effector's code.

INFO

This is an API reference article, for the Redux -> Effector migration guide see the "Migrating from Redux to Effector" article.

Installation

First, you need to install package:

sh
pnpm install @withease/redux
sh
yarn add @withease/redux
sh
npm install @withease/redux

API

createReduxIntegration

Effector <-> Redux interoperability works through special "interop" object, which provides Effector-compatible API to Redux Store.

ts
const myReduxStore = configureStore({
  // ...
});

const reduxInterop = createReduxIntegration({
  reduxStore: myReduxStore,
  setup: appStarted,
});

Explicit setup event is required to initialize the interoperability. Usually it would be an appStarted event or any other "app's lifecycle" event.

You can read more about this practice in the "Explicit start of the app" article.

Async setup

You can also defer interop object initialization and Redux Store creation.

The createReduxIntegration overload without explicit reduxStore allows you to pass the Store via setup event later.

ts
// src/shared/redux-interop
export const startReduxInterop = createEvent<ReduxStore>();
export const reduxInterop = createReduxIntegration({
  setup: startReduxInterop,
});

// src/entrypoint.ts
import { startReduxInterop } from 'shared/redux-interop';

const myReduxStore = configureStore({
  // ...
});

startReduxInterop(myReduxStore);
// or, if you use the Fork API
allSettled(startReduxInterop, {
  scope: clientScope,
  params: myReduxStore,
});

In that case the type support for reduxInterop.$state will be slightly worse and reduxInterop.dispatch will be no-op (and will show warnings in console) until interop object is provided with Redux Store.

☝️ This is useful, if your project has cyclic dependencies.

Interoperability object

Redux Interoperability object provides few useful APIs.

reduxInterop.$state

This is an Effector's Store, which contains the state of the provided instance of Redux Store.

It is useful, as it allows to represent any part of Redux state as an Effector store.

ts
import { combine } from 'effector';

const $user = combine(reduxInterop.$state, (x) => x.user);

TIP

Notice, that reduxInterop.$state store will use Redux Store typings, if those are provided. So it is recommended to properly type your Redux Store.

reduxInterop.dispatch

This is an Effector's Effect, which calls Redux Store's dispatch method under the hood. Since it is a normal Effect - it supports all methods of Effect type.

TIP

It is recommended to create separate events for each specific action via .prepend method of Effect.

ts
const updateUserName = reduxInterop.dispatch.prepend((name: string) =>
  userSlice.changeName(name)
);

sample({
  clock: saveButtonClicked,
  source: $nextName,
  target: updateUserName,
});

It is also possible to convert a Redux Thunk to Effect by using Effector's attach operator.

ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { attach } from 'effector';

const someThunk = createAsyncThunk(
  'some/thunk',
  async (p: number, { dispatch }) => {
    await new Promise((resolve) => setTimeout(resolve, p));

    return dispatch(someSlice.actions.doSomething());
  }
);

/**
 * This is a redux-thunk, converted into an effector Effect.
 *
 * This allows gradual migration from redux-thunks to effector Effects
 */
const someThunkFx = attach({
  mapParams: (p: number) => someThunk(p),
  effect: interop.dispatch,
});

const promise = someThunkFx(42);
// ☝️ `someThunk` will be dispatched under the hood
// `someThunkFx` will return an Promise, which will be resolved once someThunk is resolved

reduxInterop.$reduxStore

This is an Effector's Store, which contains provided instance of Redux Store.

It is useful, since it makes possible to use Effector's Fork API to write tests for the logic, contained in the Redux Store!

So even if the logic is mixed between the two like this:

ts
// app code
const myReduxStore = configureStore({
  // ...
});

const reduxInterop = createReduxIntegration({
  reduxStore: myReduxStore,
  setup: appStarted,
});

// user model
const $user = combine(reduxInterop.$state, (x) => x.user);

const updateUserName = reduxInterop.dispatch.prepend((name: string) =>
  userSlice.changeName(name)
);

sample({
  clock: saveButtonClicked,
  source: $nextName,
  target: updateUserName,
});

It is still possible to write a proper test like this:

ts
test('username updated after save button click', async () => {
  const mockStore = configureStore({
    // ...
  });

  const scope = fork({
    values: [
      // Providing mock version of the redux store
      [reduxInterop.$reduxStore, mockStore],
      // Mocking anything else, if needed
      [$nextName, 'updated'],
    ],
  });

  await allSettled(appStarted, { scope });

  expect(scope.getState($userName)).toBe('initial');

  await allSettled(saveButtonClicked, { scope });

  expect(scope.getState($userName)).toBe('updated');
});

☝️ This test will be especially useful in the future, when this part of logic will be ported to Effector.

TIP

Notice, that it is recommended to create a mock version of Redux Store for any tests like this, since the Store contains state, which could leak between the tests.

Released under the MIT License.