electric
electric copied to clipboard
feat: ssr improvements
I dedicated some time to experimenting with the code above and made several important enhancements:
-
Refactoring ElectricScripts:
- I moved
ElectricScriptsinsideElectricProvider. This change allowsElectricProviderto manage the following:- Server-Side: It ensures that a
<script>tag containing__ELECTRIC_SSR_STATE__is added to the document. - Client-Side: It guarantees that
__ELECTRIC_SSR_STATE__is correctly parsed on the client, allowing it to be set in the state and facilitating successful hydration. - Serialization Utilities: I included utilities for serialization and deserialization to streamline this process.
- Server-Side: It ensures that a
- I moved
-
Support for RSC:
- I added support for React Server Components (RSC). When a server component is executed, we now preload its shape, serialize it, and pass it as a prop to a client component. The server component handles the remaining tasks by transferring the shape to the client.
-
useShape
- Improved the
useShapehook allowing to accept an already initialized shape as a parameter.
Related to #2232 and #2219
Examples
Next.js
- Pages Router
// @/pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
return (
<ElectricProvider>
<Component {...pageProps} />
</ElectricProvider>
)
}
// @/pages/ssr/index.tsx
export const getServerSideProps: GetServerSideProps<{
shape: SerializedShapeData<Item>
}> = async () => {
const shape = await preloadShape<Item>(itemShapeOptions)
return {
props: {
shape: serializeShape(shape),
},
}
}
export default function Page(props: { shape: SerializedShapeData<Item> }) {
return <ItemsList initialShape={props.shape} />
}
// @/app/items-list.tsx
'use client'
export function ItemsList({
initialShape,
}: {
initialShape: SerializedShapeData<Item>
}) {
const { data: items } = useShape({
...getClientShapeOptions(),
initialShape,
})
// load items
}
- App Router
// @/app/page.tsx
export default async function Page() {
const shape = await preloadShape<Item>(itemShapeOptions)
return <ItemsList initialShape={serializeShape(shape)} />
}
// @/app/items-list.tsx
'use client'
export function ItemsList({
initialShape,
}: {
initialShape: SerializedShapeData<Item>
}) {
const { data: items } = useShape({
...getClientShapeOptions(),
initialShape,
})
// load items
}
Todo
- When a shape is transmitted from a server to a client component, we should consider removing the explicit call to
serializeShape. Instead, we could implement atoStringmethod within theShapeclass that internally callsserializeShape. This approach would streamline the process and enhance code readability. - Additionally, we need to refine the implementation in
client.ts. Currently, I am passing theisLiveparameter to indicate that the stream should switch to live mode immediately. I'm pretty sure there is better approach on this.
Good stuff!
We're busy getting Electric Cloud ready for the first folks next week so won't time for a bit to give this serious attention but a few thoughts:
- my thought on naming is to copy tanstack/query as that has mindshare (and their names are good) — so
HydrationBoundaryetc. https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr - a user shouldn't need to pass hydrated data into
useShape— when creating shapes, we can just look at the SSRed shapes and use them if available. Automatic hydration is good! - we don't want to return data after preloading as the data is already stored in a global cache so you don't need to explicitly pass anything around — see e.g. the remix example. You just preload in the loader and then the data is available in the component https://github.com/electric-sql/electric/pull/2232/files#diff-b1f39209f5094c949b3ba874893a9ae5da10eeca1f644489f05654aa6b172965
Here are some of my thoughts:
- I agree. Adopting naming conventions similar to those used in
@tanstack/queryis a logical approach. - Yes, ideally we don't want to pass an
initialShapeto theuseShapefunction and let the hook to automatically determine the correct shape. However, this is not that straightforward because we rely on theoptionsas the cache key and can't predict thehandleandoffsetproperties during theuseShapecall. Perhaps we could generate a key ignoring those properties? - I believe there will be cases where users need to handle shapes on the server, and the
preloadShapefunction simplifies this process by eliminating the need for them to write extra code to retrieve shapes from the cache.
However, this is not that straightforward because we rely on the options as the cache key and can't predict the handle and offset properties during the useShape call. Perhaps we could generate a key ignoring those properties?
Ah yes — handle/offset aren't part of the shape definition — they're just parameters for traversing the shape log — so yes, we shouldn't use them when generating the shape key.
I believe there will be cases where users need to handle shapes on the server, and the preloadShape function simplifies this process by eliminating the need for them to write extra code to retrieve shapes from the cache.
Sure yeah this could happen. Generally though I assume they'll preload in the server component and then the client component will have a useShape and grab the data during SSR from the server cache.
Sure yeah this could happen. Generally though I assume they'll preload in the server component and then the client component will have a useShape and grab the data during SSR from the server cache.
Yes, but I assume we will still allow access to the data on the server only?
Yeah for sure. It seems how it should work is that only data you pass into the HydrationBoundary would get sent through.
I updated the code which looks like that now:
- Pages Router
// @/pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
return (
<HydrationBoundary>
<Component {...pageProps} />
</HydrationBoundary>
)
}
// @/pages/ssr/index.tsx
export const getServerSideProps: GetServerSideProps<{
shape: SerializedShapeData<Item>
}> = async () => {
await preloadShape<Item>(itemShapeOptions)
return {
props: {},
}
}
export default function Page() {
return <ItemsList />
}
// @/app/items-list.tsx
'use client'
export function ItemsList() {
const { data: items } = useShape(getClientShapeOptions())
// load items
}
- App Router
// @app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<HydrationBoundary>
{children}
</HydrationBoundary>
</body>
</html>
)
}
// @/app/page.tsx
export default async function Page() {
await preloadShape<Item>(itemShapeOptions)
return <ItemsList />
}
// @/app/items-list.tsx
'use client'
export function ItemsList() {
const { data: items } = useShape(getClientShapeOptions())
// load items
}
Here are some of my thoughts:
-
We should consider modularising the exports from the
@electric-sql/reactpackage. Previously, all exports were bundled together insrc/index.tsx, resulting in a single import from the consuming app. However, this approach isn’t ideal with react server components (RSC). Client-side components must use theuse clientdirective in the output bundle, which interferes with code that is used on a server component and nextjs complains. Maybe we can update our exports to follow a model like this:server only,client onlyandhybrid. -
While loading items from cache works well for Pages Router, this design is a bit problematic for the App router. A server component won't wait for the hydration script to load and populate the cache, so there are cases where a component tries to load and the cache is empty. I'm not sure how much of an issue this will be (I don't get a hydration error), but sometimes I get the below logs.
- Allowing the server component to pass a shape as a prop to the client component, and then having the client component utilise the
useShapehook with this shape as its initial option, appears to be a clearer approach and removes the dependency on cache. As such, I would suggest to still keep theinitialShapeoption in theuseShapehook.
- Allowing the server component to pass a shape as a prop to the client component, and then having the client component utilise the
Thanks for the contribution, and sorry we've let it get stale. I've closed the "parent" PR for this branch because it was also very stale and is currently superseded by work in TanStack DB (as per @samwillis).