The SID is a Stable IDentifier of an effector unit. It can be used for different purposes, but the main use case is Server Side Rendering.
The SIDs have two important properties:
- They are unique – each SID for each unit should be unique.
- They are stable between different environments – each SID of each unit in some environment (e.g. server code) should be equal to a SID of this unit in any other environment (e.g. client code).
How to add SIDs
The SIDs can be added manually and automatically, but it is important that they are set before any bundling happens — otherwise, there is no way to guarantee stability.
Manual way
import { createStore } from "effector";
export const $myStore = createStore(42, { sid: "my-stable-id" });
It is important, that all SIDs are also unique. If you are using the manual way, you have to guarantee that by yourself.
Automatic way
For sure, manually creating unique ids is a quite boring job.
Thankfully, there are effector/babel-plugin
and @effector/swc-plugin
, which will provide SIDs automatically.
Because code-transpilation tools are working at the file level and are run before bundling happens – it is possible to make SIDs stable for every environment.
It is preferable to use effector/babel-plugin
or @effector/swc-plugin
instead of adding SIDs manually.
Why do we need Stable Identifiers
Because of multi-store architecture, Effector code in the applications is written in atomic and distributed way and there is no single central “root store” or “controller”, which needs to be notified about all stores/reducers/etc, created anywhere in the app.
And since there is no central “root store” – no additional setup (like Reducer Manager, etc) is required to support micro-frontends and code-splitting, everything works out of the box.
Code example
Notice, that there is no central point at all – any event of any “feature” can be triggered from anywhere and the rest of them will react accordingly.
// src/features/first-name/model.ts
import { createStore, createEvent } from "effector";
export const firstNameChanged = createEvent<string>();
export const $firstName = createStore("");
$firstName.on(firstNameChanged, (_, firstName) => firstName);
// src/features/last-name/model.ts
import { createStore, createEvent } from "effector";
export const lastNameChanged = createEvent<string>();
export const $lastName = createStore("");
$lastName.on(lastNameChanged, (_, lastName) => lastName);
// src/features/form/model.ts
import { createEvent, sample, combine } from "effector";
import { $firstName, firstNameChanged } from "@/features/first-name";
import { $lastName, lastNameChanged } from "@/features/last-name";
export const formValuesFilled = createEvent<{ firstName: string; lastName: string }>();
export const $fullName = combine($firstName, $lastName, (first, last) => `${first} ${last}`);
sample({
clock: formValuesFilled,
fn: (values) => values.firstName,
target: firstNameChanged,
});
sample({
clock: formValuesFilled,
fn: (values) => values.lastName,
target: lastNameChanged,
});
If this application was a SPA or any other kind of client-only app — this would be the end of the article.
Serialization boundary
But in the case of Server Side Rendering, there is always a serialization boundary — a point, where all state is stringified, added to a server response, and sent to a client browser.
Problem
And at this point we still need to collect the states of all stores of the app somehow!
Also, after the client browser has received a page — we need to “hydrate” everything back: unpack these values at the client and add this “server-calculated” state to client-side instances of all stores.
Solution
This is a hard problem and to solve this, effector
needs a way to connect the “server-calculated” state of some store with its client-side instance.
While it could be done by introducing a “root store” or something like that, which would manage store instances and their state for us, it would also bring to us all the downsides of this approach, e.g. much more complicated code-splitting – so this is still undesirable.
This is where SIDs will help us a lot. Because SID is, by definition, the same for the same store in any environment, effector
can simply rely on it to handle state serializing and hydration.
Example
This is a generic server-side rendering handler. The renderHtmlToString
function is an implementation detail, which will depend on the framework you use.
// src/server/handler.ts
import { fork, allSettled, serialize } from "effector";
import { formValuesFilled } from "@/features/form";
async function handleServerRequest(req) {
const scope = fork(); // creates isolated container for application state
// calculates the state of the app in this scope
await allSettled(formValuesFilled, {
scope,
params: {
firstName: "John",
lastName: "Doe",
},
});
// extract scope values to simple js object of `{[storeSid]: storeState}`
const values = serialize(scope);
const serializedState = JSON.stringify(values);
return renderHtmlToString({
scripts: [
`
<script>
self._SERVER_STATE_ = ${serializedState}
</script>
`,
],
});
}
Notice, that there are no direct imports of any stores of the application here. The state is collected automatically and its serialized version already has all the information, which will be needed for hydration.
When the generated response arrives in a client browser, the server state must be hydrated to the client stores. Thanks to SIDs, state hydration also works automatically:
// src/client/index.ts
import { Provider } from "effector-react";
const serverState = window._SERVER_STATE_;
const clientScope = fork({
values: serverState, // simply assign server state to scope
});
clientScope.getState($lastName); // "Doe"
hydrateApp(
<Provider value={clientScope}>
<App />
</Provider>,
);
At this point, the state of all stores in the clientScope
is the same, as it was at the server and there was zero manual work to do it.
Unique SIDs
The stability of SIDs is ensured by the fact, that they are added to the code before any bundling has happened.
But since both babel
and swc
plugins are able “to see” contents of one file at each moment, there is a case, where SIDs will be stable, but might not be unique
To understand why, we need to dive a bit deeper into plugin internals.
Both effector
plugins use the same approach to code transformation. Basically, they do two things:
- Add
sid
-s and any other meta-information to raw Effector’s factories calls, likecreateStore
orcreateEvent
. - Wrap any custom factories with
withFactory
helper that allows you to makesid
-s of inner units unique as well.
Built-in unit factories
Let’s take a look at the first case. For the following source code:
const $name = createStore(null);
The plugin will apply these transformations:
const $name = createStore(null, { sid: "j3l44" });
Plugins create sid
-s as a hash of the location in the source code of a unit. It allows making sid
-s unique and stable.
Custom factories
The second case is about custom factories. These are usually created to abstract away some common pattern.
Examples of custom factories:
createQuery
,createMutation
fromfarfetched
debounce
,throttle
, etc frompatronum
- Any custom factory in your code, e.g. factory of a feature-flag entity
For this explanation, we will create a very simple factory:
// src/shared/lib/create-name/index.ts
export function createName() {
const updateName = createEvent();
const $name = createStore(null);
$name.on(updateName, (_, nextName) => nextName);
return { $name };
}
// src/feature/persons/model.ts
import { createName } from "@/shared/lib/create-name";
const personOne = createName();
const personTwo = createName();
First, the plugin will add sid
to the inner stores of the factory
// src/shared/lib/create-name/index.ts
export function createName() {
const updateName = createEvent();
const $name = createStore(null, { sid: "ffds2" });
$name.on(updateName, (_, nextName) => nextName);
return { $name };
}
// src/feature/persons/model.ts
import { createName } from "@/shared/lib/create-name";
const personOne = createName();
const personTwo = createName();
But it’s not enough, because we can create two instances of createName
and internal stores of both of these instances will have the same SIDs! These SIDs will be stable, but not unique.
To fix it we need to inform the plugin about our custom factory:
// .babelrc
{
"plugins": [
[
"effector/babel-plugin",
{
"factories": ["@/shared/lib/create-name"]
}
]
]
}
Since the plugin “sees” only one file at a time, we need to provide it with the actual import path used in the module.
If relative import paths are used in the module, then the full path from the project root must be added to the factories
list, so the plugin could resolve it.
If absolute or aliased (like in the example) paths are used, then specifically this aliased path must be added to the factories
list.
Most of the popular ecosystem projects are already included in plugin’s default settings.
Now the plugin knows about our factory and it will wrap createName
with the internal withFactory
helper:
// src/shared/lib/create-name/index.ts
export function createName() {
const updateName = createEvent();
const $name = createStore(null, { sid: "ffds2" });
$name.on(updateName, (_, nextName) => nextName);
return { $name };
}
// src/feature/persons/model.ts
import { withFactory } from "effector";
import { createName } from "@/shared/lib/create-name";
const personOne = withFactory({
sid: "gre24f",
fn: () => createName(),
});
const personTwo = withFactory({
sid: "lpefgd",
fn: () => createName(),
});
Thanks to that sid
-s of inner units of a factory are also unique, and we can safely serialize and deserialize them.
personOne.$name.sid; // gre24f|ffds2
personTwo.$name.sid; // lpefgd|ffds2