Reactivity Without Hooks or Signals: Is Fict Ready?
Fict is a new framework with a simple promise. You write plain JavaScript, and a compiler makes it reactive. You don’t need useMemo, dependency arrays or .value.
Svelte has tried this before. Svelte 4 inferred reactivity at compile time, ran into blind spots, and Svelte 5 switched to explicit runes. Fict bets the other way, on a compiler smart enough to infer what Svelte 4 couldn’t.
This article is a test of that bet. I built an admin app with Fict, then I built the same admin again in React, Solid, Svelte and Vue, to have something to compare with. I wanted to know if we could use Fict on our next projects at Marmelab.
What I learned applies to any framework where the compiler finds your dependencies before the code runs. The compiler can only track what it can see, so when it can’t see your state, you have to point a finger at it. And sometimes, the compiler doesn’t even tell you that it lost track.
Everything below was tested on fict@0.36.0, the latest version as I write this. I started on 0.21.0, and as you will see, the version matters.
How Does Fict Work?
Like most developers, I started with the classic demo and built a counter.
import { $state, render } from "fict";
export function Counter() { let count = $state(0); const doubled = count * 2;
return ( <div class="counter"> <h1>Fict Counter</h1> <div class="card"> <button onClick={() => count--}>-</button> <span class="count">{count}</span> <button onClick={() => count++}>+</button> </div> <p class="doubled">Doubled: {doubled}</p> </div> );}
render(() => <Counter />, document.getElementById("app")!);Fict uses JSX like React, so if you know JSX, you won’t be surprised.
$state Function
let count = $state(0) tells the compiler to track this value. You only need a few of Fict’s functions, and $state is the main one.
Under the hood, Fict uses signals, like Solid.js. The compiler turns count into a signal, every read into a call and every write into an update, but you never see any of it in your code.
const doubled = count * 2 is just plain JavaScript, but it’s reactive too: the compiler sees that doubled depends on count, and keeps it up to date for you. If you use it in several places, it becomes a memo, a cached value that is only recomputed when count changes.
You can also mutate the state directly. count++ works, and the update is propagated to everything that reads count.
Good to know: The component itself runs only once. When
countchanges, Fict only updates the two text nodes that showcountanddoubled, and nothing else re-runs.
If you know Svelte 5, this will look familiar. Svelte also has $state and $effect. But in Svelte, you mark derived values yourself with $derived. In Fict, the compiler finds them.
$effect Function
$effect is the second function to know. As the name hints, it’s used for side effects. You can use it like this:
import { $state, $effect } from "fict";
export function Counter() { let count = $state(0);
$effect(() => { document.title = `Clicked ${count} times`; });
return <button onClick={() => count++}>{count}</button>;}There is no dependency array. The compiler sees that the effect reads count, so Fict runs it again when count changes. If the effect returns a function, Fict calls it before the next run and when the component unmounts.
Props stay reactive, even when you destructure them, which is not the case in Solid. Also, for conditional rendering, a plain if works.
What the Compiler Does
Fict doesn’t need a special file format or an extra CLI. The setup is a Vite plugin, @fictjs/vite-plugin, and "jsxImportSource": "fict" in your tsconfig.json. The compiler itself is written in Rust, and npm installs the right binary for your platform.
At build time, the compiler transforms each component. $state becomes a signal, and derived values become memos or inline updates. The JSX becomes an HTML template, and only its dynamic parts get updated. There is no virtual DOM.
The other difference from Svelte is when the tracking happens. Svelte has its own .svelte files, and tracks dependencies at runtime. Fict uses plain TSX, and finds dependencies at compile time, before the code runs. That difference explains most of the problems explained later in this article.
Svelte 4 used to work like Fict. It inferred reactivity at compile time, and it had blind spots: a reactive $: statement only saw the variables written directly in it, not the ones read inside a function it called. Svelte 5 fixed this by going explicit, with runes like $state and $derived. Fict borrows the $state name, but leaves the rest to the compiler.
My First Impressions
I’m fairly impressed by the simplicity of the basic components. It feels like plain JavaScript, and it shows that Fict has a good base.
It also has advanced features. The core provides Suspense, error boundaries, portals and transitions. fict/plus adds data fetching with resource, lazy loading and deep stores. And there are official add-ons for routing, server-side rendering and devtools.
For performance, Fict claims to be close to Solid, Svelte and Vue Vapor, Vue’s new compilation mode without a virtual DOM. I measured it too, and the results are at the end of this article.
What the Compiler Can’t See
Basic components are great for learning a framework, but I wanted to see how Fict handles a real app. So I built a small admin app, with a sidebar, a list view, a detail view, and data from an API.

Then I built the exact same admin in React, Solid, Svelte and Vue 3.6 in Vapor Mode. All five apps have the same features, hash routing, API and CSS. Only the framework changes, so for every problem below, I can show how the others handled it.
Vue 3.6 is still a release candidate. I used
3.6.0-rc.9, while the stable version is 3.5.
And spoiler alert, it didn’t go as smoothly as with the counter.
When the Compiler Says No
I needed a router. Fict provides one as an add-on, @fictjs/router. But a hash router is only a few lines, and I wanted to write my own, to see if it stayed this simple or needed a lot of glue code. I later wrote the same one in the four other apps. Here is my first version, with $state and $effect:
import { $state, $effect } from "fict";
export function Admin({ resources }: { resources: ResourceConfigs }) { let hash = $state(window.location.hash);
$effect(() => { const handler = () => (hash = window.location.hash); window.addEventListener("hashchange", handler); return () => window.removeEventListener("hashchange", handler); }); // [...]}And the build failed:
[fict] Transform failed for src/admin/Admin.tsx:[FICT-R005] function captures reactive variable(s): hash;the callback may escape its reactive owner help: pass captured values as parameters or memoize explicitlyThis is with the default config. Fict’s strict mode is on by default, and forced on for production builds: when the compiler can’t prove that an update will propagate, the build fails. Fict does this on purpose, as it prefers a build error to a UI that silently stops updating.
The error puzzled me at first. I used $state and $effect, as the docs say. Even the $effect example in Fict’s own API reference fails to build, with FICT-R005 and FICT-R002, and it’s just a setInterval that increments a $state.
The issue is that my callback captures hash, and I give it to the browser. For Fict, the callback escapes its reactive owner, the component. The compiler can’t see what the browser will do with it, or when, so it refuses to guess.
Now, here is the router from my Svelte admin:
let hash = $state(window.location.hash);
$effect(() => { const handler = () => (hash = window.location.hash); window.addEventListener("hashchange", handler); return () => window.removeEventListener("hashchange", handler);});It’s the same code, and Svelte compiles it. In Svelte, $state is a signal at runtime, so writing it from a browser callback is just a function call. Fict wants to prove at compile time where every reactive value goes, and here, it can’t. Using onMount instead of $effect, which fits a listener better, gives the same error.
In Fict, the solution I found was to drop $state and write the signal myself, with createSignal. It’s the low-level API behind $state, and Fict’s own router uses it too. I put it at the module level:
import { $effect } from "fict";import { createSignal } from "fict/advanced";
const hashSignal = createSignal(window.location.hash);
export function Admin({ resources }: { resources: ResourceConfigs }) { $effect(() => { const handler = () => hashSignal(window.location.hash); window.addEventListener("hashchange", handler); return () => window.removeEventListener("hashchange", handler); }); // [...]}But that was not enough. A const in a component is only tracked when it depends on $state, a $store or props, so if I read my signal into a const, the value freezes, and nothing warns me.
So I had to read the route through a function, called from the JSX:
function currentRoute(): Route { const path = hashSignal().replace(/^#\/?/, ""); const [resource = "", idStr] = path.split("/"); const id = idStr != null ? parseInt(idStr, 10) : NaN; return { resource, id: !isNaN(id) ? id : null };}And everything that depends on the route had to go to a child component, as a prop:
<main class="admin-content"> <AdminContent resources={resources} route={currentRoute()} /></main>It works, but that’s three workarounds for one router, and none of them looks like plain JavaScript.
Later, I found a shorter way. A $store, Fict’s deep reactive object, can be written from a browser callback when it’s created inside the component:
import { $store, onMount } from "fict";
export function Admin({ resources }: { resources: ResourceConfigs }) { const router = $store({ hash: window.location.hash });
onMount(() => { const handler = () => (router.hash = window.location.hash); window.addEventListener("hashchange", handler); return () => window.removeEventListener("hashchange", handler); });
const route = parseRoute(router.hash); // [...]}parseRoute() is the same parsing as currentRoute(), with the hash as a parameter. This compiles, and since route depends on a $store, it’s a plain const that stays reactive, with no module-level signal. Nothing in the error message pointed me there, though.
When Plain JavaScript Isn’t Enough
I wanted to fetch some data for my admin, so I used resource from fict/plus. It fetches the data, caches it, and supports Suspense, which shows a fallback while the data loads. Fict is the only one of the five frameworks that ships a request cache. For React, Solid, Svelte and Vue, I had to write my own. I wrote my resource in a file, like this:
export const getList = resource({ key: ({ resource }) => resource, suspense: true, fetch: async ({ signal }, { resource }) => { const response = await fetch(`${DEFAULT_DATA_PROVIDER}/${resource}`, { signal, }); if (!response.ok) { throw new Error(`Failed to fetch ${resource}`); } return response.json(); },});Nothing really special here. A resource is defined once, at module level. fetch receives an abort signal and the arguments, and key turns the arguments into a cache key. Then components call read() with the arguments only, as Fict adds the signal itself. read() returns { data, loading, error }, and it only fetches again when the key changes. With suspense: true, the component waits for the data, and the nearest <Suspense> shows its fallback in the meantime.
Then I read it in my list component:
export function List({ resource }: { resource: ResourceConfig }) { const record = getList.read({ resource: resource.name }); // [...]}For a React developer, calling read() directly in the component body looks like fetching during render, outside of any useEffect. In Fict, it’s fine. The component runs once, so there is no re-render to protect against. The compiler wraps the call in a memo, and it only runs again when its argument changes.
That’s the idea, at least. But I got another build error:
[fict] Transform failed for src/admin/List.tsx:[FICT-R002] reactive value escapes scope when passed to an unknown function;dependency tracking may be imprecise help: pass an accessor, memoize explicitly, or use a known synchronous hostThe cause is the same as with the router. resource is a prop, so it’s reactive. I passed it straight into getList.read(), a function the compiler doesn’t know. The compiler can’t prove what this function will do with it, so strict mode refuses it.
The fix was to give the value a name in the component first. This time, a const is fine, because it depends on a prop:
export function List({ resource }: { resource: ResourceConfig }) { const params = { resource: resource.name }; const record = getList.read(params); // [...]}The error message gives a hint, “pass an accessor”, so I tried it. A plain function, () => ({ resource: resource.name }), fails too. Fict also documents an accessor form for resources, reactive(() => …). But it only compiles when the resource is declared in the same file as the component. Mine are in a data provider module, so it fails as well.
Then I moved the resource next to the component, and found the worst case. There, my named value still compiles, but it silently reads the data only once. Same component, same line, and whether it stays reactive depends on the file where getList is declared.
In Solid, an accessor is the normal way to do this, outside JSX:
const [rows] = createResource( () => props.resource.name, (name) => getList(name),);It’s more verbose, but the function shows where the tracking happens, wherever getList is declared. And in Svelte, $derived(getList(resource.name)) works as is.
The list view hit the same rule again. Reading the resource prop even once inside a .map() callback fails too, while reading it at the top of the JSX is fine, so I had to copy everything the row template needs into constants first:
const fields = resource.listFields || [];const name = resource.name;const showFields = resource.showFields;In the detail view, the fields come from the config, so the key is only known at runtime. Indexing the fetched record with that key in the JSX, data[field.source], is another build error: FICT-H, “dynamic property access widens dependency tracking”. In my list, row[field.source] compiles, because row is a plain .map() parameter, while data is reactive. So the lookup had to move into a function, where the record is a plain parameter too:
function cellValue(record: Record<string, unknown>, source: string) { return String(record[source] ?? "");}React, Solid, Svelte and Vue all read data[field.source] directly in the template, without a helper.
None of these workarounds is hard, but they all come from the same rule: the compiler needs you to name things before it can track them.
When Fict Stays Silent
A build error is annoying, but on the good side, it tells you where to look. The frozen const didn’t, and the next problem was worse.
The Error Message That Disappeared
Fict provides an ErrorBoundary with a fallback function. Fict’s docs write this function inline, but I moved it into a named function, like in my Solid app:
function errorFallback(err: unknown) { return ( <div class="error">{err instanceof Error ? err.message : String(err)}</div> );}When a fetch failed, nothing crashed, and I got no warning and no build error. I got an empty box on screen:
<div class="error"></div>The function runs, and the error is a real Error with the right message, “Failed to fetch posts with id 99999”, but the text never reaches the DOM.
So I tried to make the helper a component, by capitalizing it. With the message computed in a const first, it got worse:
Error: Invalid hook call: hooks can only be used while rendering a component. at __fictUseContext at Proxy.ErrorFallback (src/admin/Admin.tsx) at toFallback at runOutsideComponentRenderThe stack trace gives a clue: Fict calls the fallback outside of any component render. A capitalized helper counts as a component, and its const needs a render to hook into, but there is none.
It only happens with async errors, like a failed fetch inside Suspense, and with a fallback written as a separate function. A synchronous error displays fine. Written inline, the same code works too, which makes the bug hard to spot.
The compiled code explains the difference. In a named function, the compiler turns the JSX into real DOM nodes and text bindings, created as soon as the function runs. Inline in a prop, it turns the same JSX into a plain description, which Fict renders later. For an async error, Fict calls the fallback from inside the effect that just failed. So the helper’s bindings belong to the part of the tree that crashed, and Fict deletes them with it. The <div> stays, but its text is gone.
The solution was to return a real component. Fict renders it later, in its own place, so its bindings survive:
function ErrorMessage({ message }: { message: string }) { return <div class="error">{message}</div>;}
function errorFallback(err: unknown) { return ( <ErrorMessage message={err instanceof Error ? err.message : String(err)} /> );}The funny part is that my Solid admin uses the exact same errorFallback function, and it works.
This one worries me the most. It’s a runtime bug, so strict mode can’t catch it: the code is valid. But nothing else said anything either, not the linter, not the console, and I almost shipped an error box that could never show a failed fetch. When you trust the compiler to catch everything, you stop looking for this kind of bug. And it’s still there in 0.36.0, the latest release.
Vue Vapor, which isn’t finished either, had a similar problem: in a pure Vapor app, <Suspense> rendered nothing at all. But Vue printed a warning pointing at <Suspense>, which Fict’s empty box never did. So in Vue, I went back to refs and v-if for loading and errors, and in Svelte, I used {#await} and {:catch}.
A Young Project
Fict says so itself: its README labels it as alpha, and says not to use it in production yet. It’s a 0.x project.
The Bug That Fixed Itself
This bug cost me the most time. On 0.21.0, my detail view was not compatible with Suspense. The code was almost the same as in the list view, but I got an infinite re-rendering loop. In dev, Fict’s cycle guard stopped it after 10,000 effect runs. In production, the tab froze.
I tried a string cache key instead of an object, since objects are recreated, but no luck. I gave up and set suspense: false on getOne. The view then showed empty fields until the data arrived.
Then I upgraded to 0.36.0 and turned suspense back on:
export const getOne = resource({ key: ({ resource, id }) => `${resource}/${id}`, suspense: true, // [...]});This time, it worked: no more loop, each record is fetched once, and coming back to a record I already visited uses the cache.
To make sure it wasn’t my code, I ran the same files on both versions, with suspense: true. On 0.21.0, the detail view loops. On 0.36.0, it doesn’t. Even the code compiled by 0.21 stops looping on the 0.36 runtime. So it was a Fict bug, fixed upstream between the two versions. Reassuring, and slightly annoying at the same time.
Fict moves fast. Between 0.21 and 0.36, in four months, it released fifteen minor versions and replaced its whole compiler, from Babel to Rust. Bugs like this one get fixed, but changes also slip through silently. On 0.21, a .map() with a key over a createSignal list compiled to a keyed list. On 0.36, the same code rebuilds every row on every change, without any warning. I’ll come back to it in the benchmark.
Docs, Community and Tooling
The documentation lives in the GitHub repository, with a long README and a docs folder, but no website yet. It’s detailed, especially on the compiler rules, but not always up to date: as we saw, the $effect example in the API reference doesn’t build.
The community is small: a single maintainer, about 60 stars on GitHub, and almost no issues opened by other people. There is an official router, server-side rendering, devtools, and even a skill for AI coding agents. But some add-ons lag behind the core. As I write this, the devtools don’t support Vite 8, and there is no mature form library yet.
The error messages are precise, and each one comes with a help line that suggests a fix, even if not every suggestion works, as we saw with the accessor. Two things are less pleasant. The Vite plugin doesn’t print compiler warnings unless you pass an onWarn callback. And when a file fails to compile, the error is sometimes reported for another file. Fict also has an ESLint plugin. It doesn’t catch the strict mode errors, but the compiler does, so the linter is a plus on top.
Size and Speed
Bundle Size
Here is the JavaScript bundle of each admin, built for production with Vite 8:
| Framework | Minified | Gzipped |
|---|---|---|
| Solid | 22.40 kB | 8.44 kB |
| Svelte | 43.03 kB | 16.47 kB |
| Vue 3.6 Vapor (RC) | 76.12 kB | 28.31 kB |
| Fict | 111.15 kB | 35.83 kB |
| React | 224.21 kB | 69.93 kB |
Fict is the second heaviest, after React. Gzipped, it’s half of React, but heavier than Vue Vapor, twice Svelte and four times Solid.
I expected a compiled framework to be close to Solid. It isn’t, but Fict says upfront that size is not its goal. Almost all of the bundle is Fict itself, as the app’s own code is about 8 kB.
Good to know: In Vue, if you leave
<Suspense>in the tree, part of the virtual DOM comes back into the bundle. That’s 31.87 kB gzipped instead of 28.31 kB, for a component that doesn’t even render in pure Vapor.
Runtime Performance
My own micro-benchmarks kept contradicting each other, so I used js-framework-benchmark, the reference suite that everyone quotes. I wrote a keyed Fict version following its rules, and ran it against the official versions of the other four. Keyed means that each row keeps its DOM node when the data moves.
My Fict version is the only unofficial one in the table. I learned Fict for this article, while the other versions were tuned by the people who wrote those frameworks. An expert could probably write a faster one, so these numbers may not show Fict at its best.
The table shows the median duration in ms, so lower is better. Each benchmark ran 11 times, or 21 times for select, in headless Chrome with the suite’s default CPU throttling, on a laptop with an AMD Ryzen 5 PRO 4650U. I kept five of the suite’s nine benchmarks to keep the table readable. Across all nine, Fict is 11% slower than Solid on average, the same as on these five. The versions are Fict 0.36.0 and those of the suite’s official entries: Solid 1.9.3, Svelte 5.42.1, Vue 3.6.0-rc.5 (older than the rc.9 of my admin) and React 19.2.0, without the React Compiler.
| Framework | Create 1k rows | Update every 10th | Select row | Swap rows | Create 10k rows |
|---|---|---|---|---|---|
| Solid | 79.3 | 53.0 | 13.4 | 63.7 | 825.4 |
| Vue 3.6 Vapor (RC) | 79.4 | 51.8 | 11.5 | 64.6 | 831.3 |
| Svelte | 80.8 | 53.5 | 17.8 | 64.7 | 831.8 |
| React | 94.8 | 64.9 | 19.4 | 347.5 | 1235.6 |
| Fict | 98.5 | 64.2 | 12.7 | 65.3 | 948.7 |
Solid, Svelte and Vue Vapor are within a few percent of each other, except on select. There, Vue Vapor was the fastest and Svelte the slowest in all three of my runs. On the other benchmarks, their order changes from one run to the next, so I can’t rank them.
Fict comes right behind, in the same league. Swap and select are ties with Solid, and create and update are 15% to 25% slower. Fict’s own README tells the same story: in its own run of the suite, Fict is about 7% behind Solid on average. Against React, Fict is about 4% slower at creating 1,000 rows, and it ties on update. But it’s 1.3x faster on 10,000 rows, 1.5x faster on select, and it swaps rows five times faster.
My first try was much slower, though. I used $store with deep mutation, and updating every 10th row took 2,240 ms, 34 times slower than Solid in that run. The compiler doesn’t turn a .map() over a $store into a keyed list, so every update rebuilt all 1,000 rows. A list in a createSignal has the same problem, and I got no warning in either case. The version in the table uses plain $state and immutable updates, which is what the docs show by default for lists. Like the official Solid version, it also uses a selector and textContent for the cells.
The compiler had one more surprise. It rejected isSelected(row.id) inside the list. isSelected is a selector, which only updates the rows whose selection changed. So I compared selected === row.id in every row instead, and selecting one row re-ran the comparison in all 1,000 rows. But the error was a false positive. The compiler couldn’t see that row.id was a plain number, and isSelected(+row.id) compiles fine. With it, select went from 2.2 times slower than Solid to a tie.
My Final Thoughts
Every framework in this article asks you for something. React re-runs your components and wants you to list your effect dependencies. Solid wants functions, so tracking happens where you can see it, Svelte wants you to mark your state and derived values, and Vue wants them wrapped in ref() and computed(). Fict asks for almost nothing up front, but it asks later, in build errors.
That’s by design: its compiler infers what it can prove, and refuses the rest. For components that only use their own state and props, it works very well, and the counter really is plain JavaScript. Fict is also fast, close to Solid, Svelte and Vue Vapor, if you write it the way it wants. And resource() gave me a request cache that none of the four others ship. In React, I would usually use TanStack Query for that.
It gets harder at the edges, with browser events, state that comes from outside, Fict’s own resource, which behaves differently depending on the file it lives in, or UI built from config. In my admin, that was the router, the data layer and the detail view, and that’s where I spent most of my time. Every workaround came down to either giving the compiler a name it can track, or stepping outside and managing the signal yourself. And sometimes Fict fails silently: I got a const that stopped updating, a list that lost its keys, and an error box that lost its message.
If you try Fict, here is what worked for me, and what didn’t.
What works:
$statefor local state, and a plainconstfor derived values- Props, even destructured
- Lists with
.map()and akey, over a$statearray updated immutably resource()declared in its own module, read with an argument named in aconstfirstErrorBoundaryfallbacks written inline, or returning a component
What doesn’t:
- Writing
$statefrom a browser callback. Use a$storeupdated fromonMountinstead. - Reading a signal into a
const. Read it inside the JSX, or inside a function. - Reading a prop inside a
.map()callback. Copy it into aconstfirst. - A resource declared in the same file as the component. The named argument then reads the data only once.
- Indexing reactive data with a dynamic key in the JSX. Move the lookup into a helper function.
- Lists over a
$storeor acreateSignal. They lose their keys, and every update rebuilds every row. - A named fallback helper that returns JSX. On async errors, the message disappears.
Svelte answered the same blind spots by going explicit. Fict still bets on inference. I hope it wins.
So, will we use it at Marmelab? Not on a client project yet. Its own README says it’s alpha. The ideas are good, and it’s not slow, but I can’t recommend a framework where a feature can fail silently, by rendering nothing.
I really like the idea of Fict, and the project moves fast. Maybe the compiler will see more in the next versions, and I would like to write this article again in a year.
Authors
Full-stack web developer at marmelab, Guillaume was initially a Java guy. Fan of anime and video games, he can develop an AI that beats you every time.