Splitting Data Streams with split
The split
method is designed to divide logic into multiple data streams.
For example, you might need to route data differently depending on its content, much like a railway switch that directs trains to different tracks:
- If a form is filled incorrectly — display an error.
- If everything is correct — send a request.
Conditions in split
are checked sequentially from top to bottom. Once a condition matches, subsequent ones are not evaluated. Keep this in mind when crafting your conditions.
Basic Usage of split
Let’s look at a simple example — processing messages of different types:
import { createEvent, split } from "effector";
const updateUserStatus = createEvent();
const { activeUserUpdated, idleUserUpdated, inactiveUserUpdated } = split(updateUserStatus, {
activeUserUpdated: (userStatus) => userStatus === "active",
idleUserUpdated: (userStatus) => userStatus === "idle",
inactiveUserUpdated: (userStatus) => userStatus === "inactive",
});
The logic here is straightforward. When the updateUserStatus
event is triggered, it enters split
, which evaluates each condition from top to bottom until a match is found, then triggers the corresponding event in effector
.
Each condition is defined by a predicate — a function returning true
or false
.
You might wonder, “Why use this when I could handle conditions with if/else
in the UI?” The answer lies in Effector’s philosophy of separating business logic from the UI.
Think of split
as a reactive switch
for units.
Default Case
When using split
, there might be situations where no conditions match. For such cases, there’s a special default case: __
.
Here’s the same example as before, now including a default case:
import { createEvent, split } from "effector";
const updateUserStatus = createEvent();
const { activeUserUpdated, idleUserUpdated, inactiveUserUpdated, __ } = split(updateUserStatus, {
activeUserUpdated: (userStatus) => userStatus === "active",
idleUserUpdated: (userStatus) => userStatus === "idle",
inactiveUserUpdated: (userStatus) => userStatus === "inactive",
});
__.watch((defaultStatus) => console.log("default case with status:", defaultStatus));
activeUserUpdated.watch(() => console.log("active user"));
updateUserStatus("whatever");
updateUserStatus("active");
updateUserStatus("default case");
// Console output:
// default case with status: whatever
// active user
// default case with status: default case
If no conditions match, the default case __
will be triggered.
Short Form
The split
method supports multiple usage patterns based on your needs.
The shortest usage involves passing a unit as the first argument serving as a trigger and an object with cases as the second argument.
Let’s look at an example with GitHub’s “Star” and “Watch” buttons:
import { createStore, createEvent, split } from "effector";
type Repo = {
// ... other properties
isStarred: boolean;
isWatched: boolean;
};
const toggleStar = createEvent<string>();
const toggleWatch = createEvent<string>();
const $repo = createStore<null | Repo>(null)
.on(toggleStar, (repo) => ({
...repo,
isStarred: !repo.isStarred,
}))
.on(toggleWatch, (repo) => ({ ...repo, isWatched: !repo.isWatched }));
const { starredRepo, unstarredRepo, __ } = split($repo, {
starredRepo: (repo) => repo.isStarred,
unstarredRepo: (repo) => !repo.isStarred,
});
// Debug default case
__.watch((repo) => console.log("[split toggleStar] Default case triggered with value ", repo));
// Somewhere in the app
toggleStar();
This usage returns an object with derived events, which can trigger reactive chains of actions.
Use this pattern when:
- There are no dependencies on external data (e.g., stores).
- You need simple, readable code.
Expanded Form
Using the split
method in this variation doesn’t return any value but provides several new capabilities:
- You can depend on external data, such as stores, using the
match
parameter. - Trigger multiple units when a case matches by passing an array.
- Add a data source using
source
and a trigger usingclock
.
For example, imagine a scenario where your application has two modes: user
and admin
. When an event is triggered, different actions occur depending on whether the mode is user
or admin
:
import { createStore, createEvent, createEffect, split } from "effector";
const adminActionFx = createEffect();
const secondAdminActionFx = createEffect();
const userActionFx = createEffect();
const defaultActionFx = createEffect();
// UI event
const buttonClicked = createEvent();
// Current application mode
const $appMode = createStore<"admin" | "user">("user");
// Different actions for different modes
split({
source: buttonClicked,
match: $appMode, // Logic depends on the current mode
cases: {
admin: [adminActionFx, secondAdminActionFx],
user: userActionFx,
__: defaultActionFx,
},
});
// Clicking the same button performs different actions
// depending on the application mode
buttonClicked();
// -> "Performing user action" (when $appMode = 'user')
// -> "Performing admin action" (when $appMode = 'admin')
Additionally, you can include a clock
property that works like in sample
, acting as a trigger, while source
provides the data to be passed into the respective case. Here’s an extended example:
// Extending the previous code
const adminActionFx = createEffect((currentUser) => {
// ...
});
const secondAdminActionFx = createEffect((currentUser) => {
// ...
});
// Adding a new store
const $currentUser = createStore({
id: 1,
name: "Donald",
});
const $appMode = createStore<"admin" | "user">("user");
split({
clock: buttonClicked,
// Passing the new store as a data source
source: $currentUser,
match: $appMode,
cases: {
admin: [adminActionFx, secondAdminActionFx],
user: userActionFx,
__: defaultActionFx,
},
});
If you need a default case, you must explicitly define it in the cases
object, otherwise, it won’ t be processed!
In this scenario, the logic for handling cases is determined at runtime based on $appMode
, unlike the earlier example where it was defined during split
creation.
When using match
, it can accept units, functions, or objects with specific constraints:
- Store: If using a store, it must store a string value.
- Function: If passing a function, it must return a string value and be pure.
- Object with stores: If passing an object of stores, each store must hold a boolean value.
- Object with functions: If passing an object of functions, each function must return a boolean value and be pure.
match
as a Store
When match
is a store, the value in the store is used as a key to select the corresponding case:
const $currentTab = createStore("home");
split({
source: pageNavigated,
match: $currentTab,
cases: {
home: loadHomeDataFx,
profile: loadProfileDataFx,
settings: loadSettingsDataFx,
},
});
match
as a Function
When using a function for match
, it must return a string to be used as the case key:
const userActionRequested = createEvent<{ type: string; payload: any }>();
split({
source: userActionRequested,
match: (action) => action.type, // The function returns a string
cases: {
update: updateUserDataFx,
delete: deleteUserDataFx,
create: createUserDataFx,
},
});
match
as an Object with Stores
When match
is an object of stores, each store must hold a boolean value. The case whose store contains true will execute:
const $isAdmin = createStore(false);
const $isModerator = createStore(false);
split({
source: postCreated,
match: {
admin: $isAdmin,
moderator: $isModerator,
},
cases: {
admin: createAdminPostFx,
moderator: createModeratorPostFx,
__: createUserPostFx,
},
});
match
as an Object with Functions
If using an object of functions, each function must return a boolean. The first case with a true
function will execute:
split({
source: paymentReceived,
match: {
lowAmount: ({ amount }) => amount < 100,
mediumAmount: ({ amount }) => amount >= 100 && amount < 1000,
highAmount: ({ amount }) => amount >= 1000,
},
cases: {
lowAmount: processLowPaymentFx,
mediumAmount: processMediumPaymentFx,
highAmount: processHighPaymentFx,
},
});
Ensure your conditions in match
are mutually exclusive. Overlapping conditions may cause unexpected behavior. Always verify the logic to avoid conflicts.
Practical Examples
Handling forms with split
const showFormErrorsFx = createEffect(() => {
// Logic to display errors
});
const submitFormFx = createEffect(() => {
// Logic to submit the form
});
const submitForm = createEvent();
const $form = createStore({
name: "",
email: "",
age: 0,
}).on(submitForm, (_, submittedForm) => ({ ...submittedForm }));
// Separate store for errors
const $formErrors = createStore({
name: "",
email: "",
age: "",
}).reset(submitForm);
// Validate fields and collect errors
sample({
clock: submitForm,
source: $form,
fn: (form) => ({
name: !form.name.trim() ? "Name is required" : "",
email: !isValidEmail(form.email) ? "Invalid email" : "",
age: form.age < 18 ? "Age must be 18+" : "",
}),
target: $formErrors,
});
// Use split for routing based on validation results
split({
source: $formErrors,
match: {
hasErrors: (errors) => Object.values(errors).some((error) => error !== ""),
},
cases: {
hasErrors: showFormErrorsFx,
__: submitFormFx,
},
});
Explanation:
Two effects are created: one to display errors and one to submit the form.
Two stores are defined: $form
for form data and $formErrors
for errors.
On form submission submitForm
, two things happen:
- Form data is updated in the
$form
store. - All fields are validated using
sample
, and errors are stored in$formErrors
.
The split
method determines the next step:
- If any field has an error – ❌ display the errors.
- If all fields are valid – ✅ submit the form.