@effector/next
v0.7.0
Published
Minimal compatibility layer for effector + Next.js
Downloads
15,940
Keywords
Readme
Bindings for Next.js
This is minimal compatibility layer for effector + Next.js - it only provides one special EffectorNext
provider component, which allows to fully leverage effector's Fork API, while handling some special parts of Next.js SSR and SSG flow.
Installation
npm add effector effector-react @effector/next
Also, you can use Yarn or PNPM to install dependencies.
Usage
You can find example app at the apps/playground-app
- it contains both App Router and Pages Router usage examples in various cases.
SIDs
To serialize and transfer state of effector stores between the network boundaries all stores must have a Stable IDentifier - sid.
Sid's are added automatically via either built-in babel plugin or our experimental SWC plugin.
Babel-plugin
{
"presets": ["next/babel"],
"plugins": ["effector/babel-plugin"]
}
SWC Plugin
Read effector SWC plugin documentation
Effector ESlint Plugin
It is recommened to install the official Effector ESlint Plugin with React Preset, it will help you follow best practices and avoid common mistakes, like always using useUnit
.eslintrc
{
"plugins": ["effector"],
"extends": ["plugin:effector/react"]
}
Don't forget useUnit
for all effector
units
In SSR applications all of effector's units need to be "binded" to the current Scope
, which means that you should always use useUnit
in components:
import { useUnit } from "effector-react"
import { eventTriggered, effectFx, $store } from "./model"
export function Component() {
const {
value,
trigger,
callEffect
} = useUnit({
value: $store,
trigger: eventTriggered,
callEffect: effectFx
})
// rest of the components code
}
You can find full docs about useUnit
here.
The official Effector ESlint Plugin with React Preset will help you to follow this rule.
Pages Router usage
Before Next.js 13.4.0
Pages router was the main way to build Next.js applications.
The Pages Router will be supported for multiple Next.js major updates.
The @effector/next
fully supports Pages Router out of the box.
1. EffectorNext provider setup
Add provider to the pages/_app.tsx
and provide it with server-side values
import { EffectorNext } from "@effector/next";
export default function App({ Component, pageProps }: AppProps) {
return (
<main>
<EffectorNext values={pageProps.values}>
<Layout>
<Component />
</Layout>
</EffectorNext>
</main>
);
}
Notice, that EffectorNext
should get serialized scope values via props.
2. Server-side computations
Start your computations in server handlers using Fork API. Workflow is the same for all server-side functions of Next.js.
getStaticProps
example
import { fork, allSettled, serialize } from "effector";
import { pageStarted } from "../src/my-page-model";
export async function getStaticProps() {
const scope = fork();
await allSettled(pageStarted, { scope, params });
return {
props: {
// notice serialized effector's scope here!
values: serialize(scope),
},
};
}
Notice, that serialized scope values are provided via the same values
prop, which is used in the _app.tsx
for providing values to EffectorNext
.
It is up to you to pick some prop name to connect server handlers with client prop in _app.tsx
.
3. Next.js Pages Router API Usage
There is a bunch of special APIs, which work in the Pages Router mode.
See the relevant Next.js docs for API details
getServerSideProps
with { notFound: true }
You can return a special object of { notFound: true }
shape to command Next.js render the common 404 page
import { fork, allSettled, serialize } from "effector";
import { pageStarted, $isNotFound } from "../src/my-page-model";
export async function getStaticProps() {
const scope = fork();
await allSettled(pageStarted, { scope, params });
if (scope.getState($isNotFound)) {
// If, after all computations there is still something missing,
// and user is meant to see the 404
return {
notFound: true,
}
}
return {
props: {
// notice serialized effector's scope here!
values: serialize(scope),
},
};
}
☝️ This scope.getState
pattern works the same way for any other Next.js Pages Router server-side APIs, as well as for App Router APIs (see below)
You're all set. Just use effector's units anywhere in components code via useUnit
from effector-react
.
Also see the nextjs-effector
package (yeah, naming of the Next.js-related packages is kind of compicated), which provides better DX to Pages Router usage and is built on top of the @effector/next
.
App Router usage
The App Router is a new paradigm for building Next.js applications using React's latest features, which declared stable since Next.js 13.4.0
.
The @effector/next
fully supports App Router out of the box.
1. Setup provider in the Root Layout
To use client components with effector units anywhere in the tree - add EffectorNext
provider at your Root Layout
If you are using multiple Root Layouts - each one of them should also have the EffectorNext
provider.
// app/layout.tsx
import { EffectorNext } from "@effector/next";
export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<EffectorNext>{/* rest of the components tree */}</EffectorNext>
</body>
</html>
);
}
☝️ This will allow any component anywhere in the tree to access any Effector store, regardless of whether that page performs any server-side computations or not
2. Server-side computations
Server computations work in a similiar way to pages
directory, but inside Server Components of the app
pages.
In this case you will need to add the EffectorNext
provider to the tree of this Server Component and provide it with serialized scope.
// app/some-path/page.tsx
import { EffectorNext } from "@effector/next";
export default async function Page() {
const scope = fork();
await allSettled(pageStarted, { scope, params });
const values = serialize(scope);
return (
<EffectorNext values={values}>
{/* rest of the components tree */}
</EffectorNext>
);
}
☝️ This will automatically render this subtree with calculated effector's state and also will automatically "hydrate" client scope with new values, once this update is rendered in the browser.
3. Next.js API's usage
Start your computations via Fork API and use scope.getState
to extract data from stores and provide it to the Next.js API's like generateStaticParams
It should be noted, that getState
usage is typically undesirable in production code - except for the cases when you need to connect effector with some external thing or api, which is exactly the case with Next.js here.
generateStaticParams
example
See relevant Next.js docs for API details
// app/blog/[slug]/page.tsx
import { fork, allSettled, serialize } from "effector"
import { blogPostOpened, blogPostsStarted, $postsList } from "@app/features/blog"
// Return a list of `params` to populate the [slug] dynamic segment
export async function generateStaticParams() {
const scope = fork();
await allSettled(blogPostsStarted, { scope });
const posts = scope.getState($postsList); // { name: string; id: string; }[]
// map to match `[slug]` param naming
return posts.map(({ id }) => ({ slug: id }));
}
// Multiple versions of this page will be statically generated
// using the `params` returned by `generateStaticParams`
export default async function Page({ params }: { params: { slug: string } }) {
const { slug } = params;
const scope = fork();
await allSettled(blogPostOpened, { scope, params: { id: slug } });
const values = serialize(scope);
return (
<EffectorNext values={values}>
{/* rest of the components tree */}
</EffectorNext>
);
}
notFound
and redirect
example
There are few special functions, which, when called from inside of an server component, will throw a special exception, which will command Next.js to perform redirect somewhere.
The notFound
function will redirect to notFound.tsx
view for a current page.
See relevant Next.js docs for API details
// app/blog/[slug]/page.tsx
import { fork, allSettled, serialize } from "effector"
import { notFound } from "next/navigation"
import { blogPostOpened, $currentPost } from "@app/features/blog"
export default async function Page({ params }: { params: { slug: string } }) {
const { slug } = params;
const scope = fork();
await allSettled(blogPostOpened, { scope, params: { id: slug } });
if (!scope.getState($currentPost)) {
// If there is no current post available after all computations
// Next.js redirect to `notFound.tsx` is triggered
notFound()
}
const values = serialize(scope);
return (
<EffectorNext values={values}>
{/* rest of the components tree */}
</EffectorNext>
);
}
☝️ It works the same for any other of Next.js special functions
That's it.
Just write effector's models as usual and use effector's units anywhere in components code via useUnit
from effector-react
Don't forget about use client
for client components:
// src/features/blog/post.view.tsx
// you should use `use client`, if your component uses any hooks
"use client"
import { useUnit } from "effector-react"
import { Title, Content } from "@app/shared/ui-kit"
import { PostCommentsView } from "@app/features/comments"
import { $currentPost } from "./model.ts"
export function CurrentBlogPost() {
const post = useUnit($currentPost)
return (
<article>
<Title{post.title}</Title>
<Content>
{post.content}
</Content>
<PostCommentsView postId={post.id} />
</article>
)
}
Dev-Tools integration
Most of effector
dev-tools options require direct access to the scope
of the app.
At the client you can get current scope via getClientScope
function, which will return Scope
in the browser and null
at the server.
Here are few examples of @effector/redux-devtools-adapter
integration.
Pages Router Dev-Tools example
In case of Pages Router dev-tools setup must be placed at the custom App component file (pages/_app.tsx).
// pages/_app.tsx
import type { AppProps } from "next/app";
import { EffectorNext, getClientScope } from "@effector/next";
import { attachReduxDevTools } from "@effector/redux-devtools-adapter";
const clientScope = getClientScope();
if (clientScope) {
/**
* Notice, that we need to check for the client scope first
*
* It will be `null` at the server
*/
attachReduxDevTools({
scope: clientScope,
name: "playground-app",
trace: true,
});
}
function App({
Component,
pageProps,
}: AppProps<{ values: Record<string, unknown> }>) {
const { values } = pageProps;
return (
<EffectorNext values={values}>
<Component />
</EffectorNext>
);
}
export default App;
App Router Dev-Tools example
In case of the App Router dev-tools setup must be placed at the the Root Layout - this way dev-tools integration will work for all pages of the app.
Create client component
Since Redux Dev-Tools are client thing - we need to prepare it as a client component.
// src/shared/redux-dev-tools-provider.tsx
"use client";
import { getClientScope } from "@effector/next";
import { attachReduxDevTools } from "@effector/redux-devtools-adapter";
const clientScope = getClientScope();
if (clientScope) {
/**
* Notice, that we need to check for the client scope first
*
* It will be `null` at the server
*/
attachReduxDevTools({
scope: clientScope,
name: "playground-app-app-router",
trace: true,
});
}
export function ReduxDevToolsAdapter({
children,
}: {
children?: React.ReactNode;
}) {
return <>{children}</>;
}
Add this component to the Root Layout
// app/layout.tsx
import { EffectorNext } from "@effector/next";
import { ReduxDevToolsAdapter } from "app-root/shared/redux-dev-tools-provider"
export function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ReduxDevToolsAdapter />
<EffectorNext>{children}</EffectorNext>
</body>
</html>
);
}
Important caveats
There are a few special nuances of Next.js behaviour, that you need to consider.
Using Pages Router along with App Router
Be aware that Next.js basically builds two different app bundles for Pages and App Router modes.
Transitions between pages of Pages Router
and App Router
are always performed via full page reload blowing away any client state.
This is enough for gradual App Router
adoption, but if you want the best experience, you should avoid mixing different Router modes in the same application. Pages that users frequently transition between should ideally move to App Router
together.
New apps should always be started with App Router, as it is the main way to build Next.js applications now.
App Router architecture edge-case
There is a known caveat with App Router architecture, which, so far, applies to effector
and @effector/next
too:
Layout Components, Page Components and any nested React Server Components cannot be aware of each other during server rendering.
☝️ It means that if there is a some effector's store, that is used in the Layout Component and later is updated during rendering of Page Component - at the client browser there might be a layout flicker visible at the Layout Component.
It happens, because at the server, by the moment of Page Component rendering, its Layout had already rendered its part of the response, including all components in it, which will use the stores state at the moment of their rendering. So, if such store is later changed in Page Component - Layout Compoment at the client will "see" and react to this change only after hydration at the client.
You can reproduce that behavior in the example playground-app
, by changing the $counter
store, which controls the state of the counter button in the navbar which is rendered at the common layout.tsx
, in some of page.tsx
components.
The navbar counter button will first be visible with count 0
(as it is a state of the $counter
at the moment of layout render) and then it will change to whatever value was set during page.tsx
render.
Non-serializable values
If you have Effector stores that contain values that cannot be safely passed to JSON.stringify
, you will have problems passing the values to the client.
In that case, you have to specify a custom serialization rule through the store settings.
Example
Date
object will be serialized to ISO-string by default, but will not be parsed back to Date
object via JSON.parse
.
We can fix it by providing custom serialization rule.
const $date = createStore<null | Date>(null, {
serialize: {
write: (dateOrNull) => (dateOrNull ? dateOrNull.toISOString() : dateOrNull),
read: (isoStringOrNull) =>
isoStringOrNull ? new Date(isoStringOrNull) : isoStringOrNull,
},
});
Docs about custom serialize feature
Effector's serialize: "ignore"
is not recommended
Next.js network (and serialization) boundary is placed before any client components rendering, so any stores with serialize: "ignore"
setting will always use default values for renders, which may lead to confusing results.
Example
// some-module.ts
const $serverOnlyValue = createStore(null, { serialize: "ignore" })
// $someValue is a derived store - such stores are never included in effector's serialization,
// because it is always possible to safely recalculate them from parent stores
//
// But in this case, combined with `serialize: "ignore"` on parent store, it will lead to confusing result
export const $someValue = $serverOnlyValue.map(value => value ? extractSomeSafeForClientData(value) : null)
// some-component
export function Component() {
const value = useUnit($someValue)
return value ? <>{value}<> : <>No value</>
}
// pages/some-page
export async function getServerSideProps(req) {
const scope = fork()
await allSettled(appStarted, { scope, params: req })
// `scope.getState($serverOnlyValue)` is not null at this point
// as well as `scope.getState($someValue)` is correctly calculated here
// Next.js network boundary happens here and is separated from rendering components at the server
return {
props: {
values: serialize(scope)
// `scope.getState($serverOnlyValue)` is stripped off here, it's value will be default one :(
// And `$someValue` is also calculated as if `$serverOnlyValue` is still null
//
// As a result - `Component` will always render "No value"
}
}
}
Workaround
You can workaround it by using another non-derived store, which will be included in effector's serialization.
// some-module.ts
const $serverOnlyValue = createStore(null, { serialize: "ignore" })
// $someValue is non-derived and will be included in serialization, if changed
export const $someValue = createStore(null)
sample({
clock: $serverOnlyValue,
fn: value => value ? extractSomeSafeForClientData(value) : null,
target: $someValue,
})
// some-component
export function Component() {
const value = useUnit($someValue)
return value ? <>{value}<> : <>No value</>
}
// pages/some-page
export async function getServerSideProps(req) {
const scope = fork()
await allSettled(appStarted, { scope, params: req })
// `scope.getState($serverOnlyValue)` is not null at this point
// as well as `scope.getState($someValue)` is correctly calculated here
// Next.js network boundary happens here and is separated from rendering components at the server
return {
props: {
values: serialize(scope)
// `scope.getState($serverOnlyValue)` is stripped off here, it's value will be default one :(
// But since `$someValue` is also non-derived store and it was changed on `$serverOnlyValue` update
// it will be included in `values`
//
// As a result - `Component` will render `value` properly
}
}
}
Do not use $store.watch
calls for debugging
The $store.watch
method works well for speeding up debugging in SPA-like environments, because in such cases effector applications usually run without Scope
.
However, this is not the case for Next.js, which runs as an SSR application - there are Scope instances on the server and client, and the actual state of the application is also stored in Scope.
You can find more details in this article.
The problem is that $store.watch(cb)
call triggers cb
immediately with current "non-scope" state of this store - it works this way due to historical reasons, as the watch API was created before Scopes and SSR support.
You can find detailed explainer in this article: https://withease.pages.dev/magazine/watch_calls
ESM dependencies and library duplicates in the bundle
Since Next.js 12 ESM imports are prioritized over CommonJS imports. While CJS-only dependencies are still supported, it is not recommended to use them.
It may lead to duplicated instances of the library in the bundle, which in case of @effector/next
or effector
leads to weird bugs like missing context provider errors.
You can check for library duplicates in the bundle either automatically with statoscope.tech Webpack Plugin, which have special rule for this purpose.
You can also check it manually via Debug -> Sources -> Webpack -> _N_E -> node_modules
tab in the browser developer tools. Duplicated modules will be presented here in both mjs
and cjs
kinds.
Release process
- Check out the draft release.
- All PRs should have correct labels and useful titles. You can review available labels here.
- Update labels for PRs and titles, next manually run the release drafter action to regenerate the draft release.
- Review the new version and press "Publish"
- If required check "Create discussion for this release"