Migrating from Redux

Migrating from Redux

If you are coming from Redux, this guide will help you understand how Effector’s concepts map to what you already know and how to translate common Redux patterns into idiomatic Effector code.

tip

For gradual, incremental migration of an existing Redux codebase (where Redux and Effector coexist), see the dedicated @withease/redux migration guide.

This page focuses on the conceptual differences and pattern translations to help you think in Effector.

Concept mapping

ReduxEffectorNotes
Store (single, global)Store (createStore)Effector uses many small stores instead of one big state tree
ActionEvent (createEvent)Events are first-class callable units, no action types or creators needed
Reducer.on() handlerStores subscribe to events via .on(event, reducer)
Thunk / SagaEffect (createEffect)A container for side effects with built-in pending/done/fail events
useSelectoruseUnitFrom effector-react; subscribes to stores reactively
useDispatchuseUnitEvents and effects returned by useUnit are already bound
combineReducerscombineDerives a new store from multiple stores
Middlewaresample / Effectsample connects units declaratively; effects handle side effects
Reselect (createSelector).map() / combineDerived stores update only when their source changes
Redux Toolkit createSliceStore + Events + .on()No boilerplate wrapper needed

Key differences

Many stores instead of one

Redux puts all state into a single store and uses selectors to extract slices. Effector encourages many independent stores, each holding a specific piece of state:

// Redux: one store, nested state
const initialState = { count: 0, user: null, todos: [] };
// Effector: independent stores
const $count = createStore(0);
const $user = createStore(null);
const $todos = createStore([]);

This eliminates the need for deeply nested state updates and makes each store independently testable.

No action types or switch statements

Redux actions require string types and reducers use switch statements. In Effector, events are callable functions and stores subscribe to them directly:

// Redux
const INCREMENT = "INCREMENT";
const increment = () => ({ type: INCREMENT });
function counterReducer(state = 0, action) {
switch (action.type) {
case INCREMENT:
return state + 1;
default:
return state;
}
}
// Effector
const increment = createEvent();
const $count = createStore(0).on(increment, (state) => state + 1);
// Usage: just call the event
increment();

Declarative connections with sample

Where Redux uses middleware (thunks, sagas) for orchestration, Effector uses sample to declaratively connect units:

// Redux: thunk with imperative dispatch
const fetchUser = (id) => async (dispatch) => {
dispatch(setLoading(true));
const user = await api.getUser(id);
dispatch(setUser(user));
dispatch(setLoading(false));
};
// Effector: declarative connections
const pageOpened = createEvent<number>(); // carries user id
const fetchUserFx = createEffect((id: number) => api.getUser(id));
const $user = createStore(null).on(fetchUserFx.doneData, (_, user) => user);
const $loading = fetchUserFx.pending; // built-in!
sample({
clock: pageOpened, // id comes from the event payload
target: fetchUserFx,
});

Notice that $loading comes for free β€” every effect has a .pending store and .done / .fail / .doneData / .failData events built in.

Common patterns

Counter

// Redux (with Redux Toolkit)
const counterSlice = createSlice({
name: "counter",
initialState: 0,
reducers: {
incremented: (state) => state + 1,
decremented: (state) => state - 1,
incrementedBy: (state, action) => state + action.payload,
},
});
// Effector
const incremented = createEvent();
const decremented = createEvent();
const incrementedBy = createEvent();
const $counter = createStore(0)
.on(incremented, (n) => n + 1)
.on(decremented, (n) => n - 1)
.on(incrementedBy, (n, amount) => n + amount);

Async data fetching

// Redux: createAsyncThunk
const fetchTodos = createAsyncThunk("todos/fetch", async () => {
const response = await fetch("/api/todos");
return response.json();
});
const todosSlice = createSlice({
name: "todos",
initialState: { items: [], status: "idle", error: null },
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = "loading";
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = "succeeded";
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = "failed";
state.error = action.error.message;
});
},
});
// Effector
const fetchTodosFx = createEffect(async () => {
const response = await fetch("/api/todos");
return response.json();
});
const $todos = createStore([]).on(fetchTodosFx.doneData, (_, todos) => todos);
const $loading = fetchTodosFx.pending;
const $error = createStore(null)
.on(fetchTodosFx.failData, (_, error) => error.message)
.reset(fetchTodosFx);

Effector’s createEffect gives you .pending, .done, .fail, .doneData, and .failData for free β€” no need to manually track loading states.

Derived state

// Redux: Reselect
const selectCompletedTodos = createSelector(
(state) => state.todos,
(todos) => todos.filter((t) => t.completed)
);
const selectCompletedCount = createSelector(
selectCompletedTodos,
(todos) => todos.length
);
// Effector: .map() and combine()
const $completedTodos = $todos.map((todos) =>
todos.filter((t) => t.completed)
);
const $completedCount = $completedTodos.map((todos) => todos.length);
// Or combine multiple stores
const $filter = createStore("all"); // e.g., "all" | "active" | "completed"
const $filteredTodos = combine($todos, $filter, (todos, filter) =>
filter === "all" ? todos : todos.filter((t) => t.status === filter)
);

Conditional logic (replacing middleware)

// Redux: middleware or thunk with conditionals
const conditionalFetch = (id) => (dispatch, getState) => {
if (getState().cache[id]) return;
dispatch(fetchItem(id));
};
// Effector: sample with filter
const itemRequested = createEvent<string>(); // carries item id
const fetchItemFx = createEffect((id: string) => api.getItem(id));
const $cache = createStore<Record<string, Item>>({});
sample({
clock: itemRequested,
source: $cache,
filter: (cache, id) => !cache[id], // skip if already cached
fn: (_, id) => id,
target: fetchItemFx,
});

React integration

// Redux
import { useSelector, useDispatch } from "react-redux";
function Counter() {
const count = useSelector((state) => state.counter);
const dispatch = useDispatch();
return <button onClick={() => dispatch(increment())}>Count: {count}</button>;
}
// Effector
import { useUnit } from "effector-react";
function Counter() {
const { count, increment: onIncrement } = useUnit({
count: $counter,
increment: incremented,
});
return <button onClick={onIncrement}>Count: {count}</button>;
}

With useUnit, stores are subscribed reactively and events are already bound β€” no dispatch wrapper needed.

Testing

Effector provides fork and allSettled for isolated testing without mocking global state:

// Redux: requires configuring a mock store
const mockStore = configureStore({ reducer: rootReducer });
mockStore.dispatch(increment());
expect(mockStore.getState().counter).toBe(1);
// Effector: fork creates an isolated scope
import { fork, allSettled } from "effector";
const scope = fork();
await allSettled(incremented, { scope });
expect(scope.getState($counter)).toBe(1);

Each test gets a completely independent scope with no shared mutable state between tests.

Migration strategy

  1. Start small: Pick one isolated feature and rewrite it in Effector
  2. Coexist: Use @withease/redux to bridge Redux and Effector during the transition (see the gradual migration guide)
  3. Move outward: Migrate features one by one, replacing selectors with stores and thunks with effects
  4. Remove Redux: Once all features are migrated, remove the Redux dependency

Further reading

Contributors