Preact Context returns initial value instead of provided value
Problem
Preact Context consistently returns the initial context value instead of the provided value during server-side rendering in Fresh, even when the provider is properly set up with a value.
Reproduction Steps
- Create a Fresh app with Vite
- Create a provider component:
import { ComponentChildren, createContext } from "preact";
import { useContext } from "preact/hooks";
export const SomeContext = createContext<User | null>(null);
export default function SomeProvider({ children }: { children: ComponentChildren }) {
return (
<SomeContext.Provider value={{ userId: "user_123" }}> {/* PROVIDED VALUE */}
{children}
</SomeContext.Provider>
);
}
export function useUser() {
const user = useContext(SomeContext); // Returns null instead of provided value
if (!user) {
throw new Error("User context not found");
}
return user;
}
- Use in
_app.tsx:
<SomeProvider>
<Component />
</SomeProvider>
- Use in page component:
export default define.page(function Home(ctx) {
const user = useUser(); // Throws error - gets null instead of provided value
return <p>User: {user?.userId}</p>;
});
- Run
deno task devand visit the page
Error output
Error: User context not found
at useUser (/path/to/components/SomeProvider.tsx:9:293)
at Object.Home (/path/to/routes/index.tsx:10:547)
Repro https://github.com/wobsoriano/fresh-preact-context-bug
Looks like useContext() indeed clashes with Fresh island rehydration somehow.
FWIW I use a similar pattern without useContext() that works fine for me, see these two files:
- https://github.com/fry69/orw/blob/fresh-vite/orw-deno/routes/(main)/_layout.tsx (
DataInitializerenclosure)- that is the equivalent to your
_app.tsx(it was before in my_app.tsxbefore I refactored
- that is the equivalent to your
- https://github.com/fry69/orw/blob/fresh-vite/orw-deno/islands/DataInitializer.tsx (actual implementation)
Interesting!
Need useContext() here as I'm building a Fresh SDK for Clerk:
The main provider - https://github.com/wobsoriano/fresh-clerk/blob/main/src/islands/ClerkProvider.tsx
and all the hooks, components and islands depend on it
I spend a bit more time with that while burning my Anthropic credits before they expire.
This seems to be a fundamental issue with how Fresh works.
Here is an analysis from Claude Code after toying with your repro repo for ~30 mins:
Generated:
Now I understand the core issue. Looking at the Fresh code, islands are isolated render boundaries. When Fresh encounters an island component, it:
- Wraps the island with markers (wrapWithMarker)
- Serializes props separately (islandProps.push)
- Renders islands independently during client hydration
This means context providers in the main render tree cannot be accessed by islands because islands are rendered in isolation.
The Real Answer: How useContext() Should Work in Fresh 2
Context providers cannot cross island boundaries in Fresh 2. Here are the correct patterns:
Working Pattern 1: Context WITHIN Islands
Put both provider and consumer in the same island:
// islands/UserIsland.tsx
import { createContext, useContext } from "preact";
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
const UserContext = createContext(null);
function UserProvider({ children }) {
const user = useSignal(null);
useEffect(() => {
setTimeout(() => user.value = { userId: "user_123" }, 1500);
}, []);
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
function UserDisplay() {
const user = useContext(UserContext);
return <p>User: {user?.value?.userId ?? "Loading..."}</p>;
}
export default function UserIsland() {
return (
<UserProvider>
<UserDisplay />
</UserProvider>
);
}
Working Pattern 2: Use Props Instead of Context
Pass data through props from route handlers:
// routes/index.tsx
import { page } from "fresh";
export const handler = {
async GET() {
const user = { userId: "user_123" };
return page({ user });
},
};
export default define.page<typeof handler>(({ data }) => (
<div>
<UserDisplay user={data.user} />
</div>
));
// islands/UserDisplay.tsx
export default function UserDisplay({ user }) {
return <p>User: {user.userId}</p>;
}
Working Pattern 3: Global State Management
Use a global signal that islands can subscribe to:
// signals/user.ts
import { signal } from "@preact/signals";
export const userSignal = signal(null);
// Auto-load user data
setTimeout(() => userSignal.value = { userId: "user_123" }, 1500);
// islands/UserDisplay.tsx
import { userSignal } from "../signals/user.ts";
// signals/user.ts
import { signal } from "@preact/signals";
export const userSignal = signal(null);
// Auto-load user data
setTimeout(() => userSignal.value = { userId: "user_123" }, 1500);
// islands/UserDisplay.tsx
import { userSignal } from "../signals/user.ts";
export default function UserDisplay() {
return <p>User: {userSignal.value?.userId ?? "Loading..."}</p>;
}
Summary
The bug you encountered reveals a fundamental Fresh 2.0 architectural limitation: Context providers in layouts/routes cannot provide context to islands because islands are isolated render boundaries.
Your original code was architecturally impossible in Fresh 2.0. The correct patterns are:
- Self-contained islands with internal context
- Props from route handlers for server data
- Global signals for shared client state
The island architecture prioritizes performance and hydration reliability over context sharing across boundaries. This is by design, not a bug - it ensures islands can hydrate independently without depending on the broader page context.
Thanks for the detailed research here @fry69 ! Note that it happens on non-island provider component too
Note that it happens on non-island provider component too
Yes. Claude played through possible combinations to get useContext() working until it finally seemed to have found the right place in the Fresh code base and surrendered.
I just found out that I still have the terminal session open, so here is the whole shebang for curious minds:
https://gist.github.com/fry69/a12447a00535c9303f7ca59a2e2ca5a0