Skip to content

Fix: Qwik Not Working — Components Not Rendering, useSignal Not Reactive, or Serialization Errors

FixDevs ·

Quick Answer

How to fix Qwik issues — component$ boundaries, useSignal and useStore reactivity, serialization with dollar signs, useTask$ and useVisibleTask$, Qwik City routing, and integration with React components.

The Problem

A Qwik component renders on the server but doesn’t become interactive in the browser:

import { component$, useSignal } from '@builder.io/qwik';

export default component$(() => {
  const count = useSignal(0);

  return (
    <button onClick$={() => count.value++}>
      Count: {count.value}
    </button>
  );
});
// Button renders but clicking does nothing

Or a serialization error appears at build time:

Error: Qwik Serialization Error: Cannot serialize class instance
Offending value: [object Date]

Or data fetching runs twice — once on the server and once on the client:

export const useUserData = routeLoader$(() => {
  console.log('Fetching user');  // Prints twice
  return fetch('/api/user').then(r => r.json());
});

Or a third-party library doesn’t work because it accesses window during SSR:

ReferenceError: window is not defined

Why This Happens

Qwik uses resumability instead of hydration. The server renders the HTML and serializes the component state into the DOM. The browser doesn’t re-execute component code until user interaction triggers it. This architecture creates specific constraints:

  • The $ suffix marks serialization boundariescomponent$(), onClick$(), useTask$(), and other $-suffixed APIs tell the Qwik optimizer where to split code. Anything crossing a $ boundary must be serializable to JSON. Classes, DOM nodes, functions (without $), and closures over non-serializable values break this contract.
  • State must use useSignal or useStore — plain JavaScript variables aren’t tracked. let count = 0 inside a component doesn’t trigger re-renders when mutated. Qwik’s reactivity system only tracks .value reads on signals and property access on stores.
  • routeLoader$ runs only on the server — it executes during SSR and its result is serialized into the HTML. It doesn’t run again on the client unless a navigation triggers it. If you see it running “twice,” it’s because a full page load (server) is followed by client-side code that also runs something.
  • Browser APIs aren’t available during SSRwindow, document, localStorage, and other browser globals don’t exist on the server. Use useVisibleTask$() for code that must run in the browser.

Fix 1: Component and Reactivity Basics

npm create qwik@latest
import { component$, useSignal, useStore, $ } from '@builder.io/qwik';

// component$ — marks a component boundary
export const Counter = component$(() => {
  // useSignal — for primitive values
  const count = useSignal(0);

  // useStore — for objects (deeply reactive)
  const state = useStore({
    items: ['Apple', 'Banana'],
    filter: '',
  });

  // $() wraps functions that cross serialization boundaries
  const increment = $(() => {
    count.value++;
  });

  const addItem = $((item: string) => {
    state.items.push(item);
  });

  return (
    <div>
      {/* Access signal with .value */}
      <button onClick$={increment}>Count: {count.value}</button>

      {/* Inline handlers also use $() syntax via onClick$ */}
      <button onClick$={() => count.value--}>Decrement</button>

      {/* Store properties are reactive */}
      <input
        value={state.filter}
        onInput$={(e: Event) => {
          state.filter = (e.target as HTMLInputElement).value;
        }}
      />

      <ul>
        {state.items
          .filter(item => item.toLowerCase().includes(state.filter.toLowerCase()))
          .map((item, i) => (
            <li key={i}>{item}</li>
          ))}
      </ul>

      <button onClick$={() => addItem('Cherry')}>Add Cherry</button>
    </div>
  );
});

Common reactivity mistakes:

// WRONG — destructuring loses reactivity
const { value } = count;  // value is a static number, not reactive
<span>{value}</span>  // Never updates

// CORRECT — access .value in JSX
<span>{count.value}</span>  // Updates when count changes

// WRONG — plain variable isn't tracked
let name = 'Alice';
<button onClick$={() => { name = 'Bob'; }}>Change</button>
// Button works but UI doesn't update

// CORRECT — use useSignal
const name = useSignal('Alice');
<button onClick$={() => { name.value = 'Bob'; }}>Change</button>

// WRONG — replacing store object
const state = useStore({ items: [] });
state = { items: ['new'] };  // ERROR — can't reassign store

// CORRECT — mutate store properties
state.items = ['new'];           // Replace array
state.items.push('new item');    // Mutate array

Fix 2: Fix Serialization Errors

Everything that crosses a $ boundary must be serializable:

// WRONG — Date is not serializable by default
const state = useStore({
  createdAt: new Date(),  // Serialization error
});

// CORRECT — store dates as strings or timestamps
const state = useStore({
  createdAt: new Date().toISOString(),  // String is serializable
});

// CORRECT — use noSerialize for non-serializable values
import { noSerialize, type NoSerialize } from '@builder.io/qwik';

const state = useStore<{ chart: NoSerialize<ChartInstance> | undefined }>({
  chart: undefined,
});

// Set non-serializable values inside useVisibleTask$ (runs only in browser)
useVisibleTask$(() => {
  const chart = new ChartLibrary('#chart');
  state.chart = noSerialize(chart);

  // Cleanup
  return () => chart.destroy();
});

// WRONG — passing a class instance through a $() boundary
class UserService {
  getUser() { return fetch('/api/user'); }
}
const service = new UserService();
const handler = $(() => service.getUser());  // Serialization error

// CORRECT — use plain functions or server$
const getUser = server$(async () => {
  return fetch('/api/user').then(r => r.json());
});
const handler = $(() => getUser());

Fix 3: Data Loading with routeLoader$ and server$

// src/routes/posts/index.tsx — Qwik City route
import { component$ } from '@builder.io/qwik';
import { routeLoader$, routeAction$, Form, z, zod$ } from '@builder.io/qwik-city';

// routeLoader$ — runs on the server during page load
export const usePostsLoader = routeLoader$(async (requestEvent) => {
  const response = await fetch('https://api.example.com/posts', {
    headers: {
      Authorization: `Bearer ${requestEvent.env.get('API_KEY')}`,
    },
  });

  if (!response.ok) {
    throw requestEvent.redirect(302, '/error');
  }

  return response.json() as Promise<Post[]>;
});

// routeAction$ — server-side mutation triggered by form submission
export const useCreatePost = routeAction$(
  async (data, requestEvent) => {
    const response = await fetch('https://api.example.com/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      return requestEvent.fail(400, { message: 'Failed to create post' });
    }

    return { success: true };
  },
  // Validate with Zod
  zod$({
    title: z.string().min(1).max(200),
    body: z.string().min(10),
  }),
);

export default component$(() => {
  const posts = usePostsLoader();  // Access loader data
  const createAction = useCreatePost();

  return (
    <div>
      {/* Loader data */}
      <ul>
        {posts.value.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      {/* Form triggers routeAction$ */}
      <Form action={createAction}>
        <input name="title" required />
        <textarea name="body" required />
        {createAction.value?.failed && (
          <p>{createAction.value.message}</p>
        )}
        <button type="submit" disabled={createAction.isRunning}>
          {createAction.isRunning ? 'Creating...' : 'Create Post'}
        </button>
      </Form>
    </div>
  );
});
// server$ — RPC-style server function callable from the client
import { server$ } from '@builder.io/qwik-city';

const searchUsers = server$(async function (query: string) {
  // This code runs on the server — access env, DB, etc.
  const db = this.env.get('DATABASE_URL');
  const results = await dbQuery(`SELECT * FROM users WHERE name LIKE $1`, [`%${query}%`]);
  return results;
});

// Call from a component — transparently makes a server request
export const SearchComponent = component$(() => {
  const results = useSignal<User[]>([]);

  return (
    <div>
      <input
        onInput$={async (e: Event) => {
          const query = (e.target as HTMLInputElement).value;
          if (query.length > 2) {
            results.value = await searchUsers(query);
          }
        }}
      />
      <ul>
        {results.value.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
});

Fix 4: useTask$ vs useVisibleTask$

Two task types serve different purposes:

import { component$, useSignal, useTask$, useVisibleTask$ } from '@builder.io/qwik';

export default component$(() => {
  const data = useSignal<string>('');
  const windowWidth = useSignal(0);

  // useTask$ — runs on server AND client
  // Tracks signal dependencies and re-runs when they change
  useTask$(({ track }) => {
    // Track a specific signal
    const value = track(() => data.value);
    console.log('Data changed to:', value);
    // This runs on the server during SSR
    // and on the client when data.value changes
  });

  // useTask$ with cleanup
  useTask$(({ track, cleanup }) => {
    const query = track(() => data.value);

    // Debounce — cleanup cancels the previous timer
    const timer = setTimeout(() => {
      // Fetch search results
    }, 300);

    cleanup(() => clearTimeout(timer));
  });

  // useVisibleTask$ — runs ONLY in the browser
  // Use for: DOM manipulation, browser APIs, third-party libraries
  useVisibleTask$(() => {
    // Safe to use window, document, localStorage
    windowWidth.value = window.innerWidth;

    const handleResize = () => {
      windowWidth.value = window.innerWidth;
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  });

  // useVisibleTask$ with strategy
  useVisibleTask$(
    () => {
      // Initialize a chart library
      const chart = new Chart('#chart', { data: [] });
      return () => chart.destroy();
    },
    {
      strategy: 'intersection-observer',  // Run when element is visible
      // strategy: 'document-ready',      // Run on DOMContentLoaded
      // strategy: 'document-idle',       // Run when browser is idle (default)
    },
  );

  return (
    <div>
      <input
        value={data.value}
        onInput$={(e: Event) => {
          data.value = (e.target as HTMLInputElement).value;
        }}
      />
      <p>Window width: {windowWidth.value}</p>
    </div>
  );
});

Fix 5: Qwik City Routing

src/routes/
├── index.tsx             # /
├── about/
│   └── index.tsx         # /about
├── posts/
│   ├── index.tsx         # /posts
│   ├── [postId]/
│   │   └── index.tsx     # /posts/:postId
│   └── layout.tsx        # Shared layout for /posts/*
├── layout.tsx            # Root layout
└── 404.tsx               # Custom 404 page
// src/routes/layout.tsx — root layout
import { component$, Slot } from '@builder.io/qwik';
import { Link, useLocation } from '@builder.io/qwik-city';

export default component$(() => {
  const loc = useLocation();

  return (
    <>
      <nav>
        <Link href="/" class={{ active: loc.url.pathname === '/' }}>
          Home
        </Link>
        <Link href="/posts/" class={{ active: loc.url.pathname.startsWith('/posts') }}>
          Posts
        </Link>
      </nav>
      <main>
        <Slot />  {/* Child route renders here */}
      </main>
    </>
  );
});

// src/routes/posts/[postId]/index.tsx — dynamic route
import { component$ } from '@builder.io/qwik';
import { routeLoader$, useLocation } from '@builder.io/qwik-city';

export const usePost = routeLoader$(async (requestEvent) => {
  const postId = requestEvent.params.postId;
  const res = await fetch(`https://api.example.com/posts/${postId}`);
  if (!res.ok) throw requestEvent.redirect(302, '/posts/');
  return res.json();
});

export default component$(() => {
  const post = usePost();

  return (
    <article>
      <h1>{post.value.title}</h1>
      <p>{post.value.body}</p>
    </article>
  );
});

Fix 6: Integrate Third-Party Libraries

Non-Qwik libraries need special handling for the $ boundary:

import { component$, useSignal, useVisibleTask$, noSerialize, type NoSerialize } from '@builder.io/qwik';

// Example: Leaflet map
export const MapComponent = component$<{ lat: number; lng: number }>(({ lat, lng }) => {
  const mapRef = useSignal<HTMLDivElement>();
  const mapInstance = useSignal<NoSerialize<L.Map>>();

  useVisibleTask$(async () => {
    // Dynamic import — loads only in browser
    const L = await import('leaflet');
    await import('leaflet/dist/leaflet.css');

    if (!mapRef.value) return;

    const map = L.map(mapRef.value).setView([lat, lng], 13);
    L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
    L.marker([lat, lng]).addTo(map);

    mapInstance.value = noSerialize(map);

    return () => map.remove();
  });

  return <div ref={mapRef} style={{ height: '400px', width: '100%' }} />;
});

// Example: Using a React component in Qwik
// Install: npm install @builder.io/qwik-react react react-dom
import { qwikify$ } from '@builder.io/qwik-react';
import { DatePicker } from 'some-react-datepicker';

// Wrap React component for Qwik
const QwikDatePicker = qwikify$(DatePicker, { eagerness: 'hover' });

export const FormWithDatePicker = component$(() => {
  const date = useSignal('');

  return (
    <QwikDatePicker
      selected={date.value}
      onChange$={(newDate: string) => {
        date.value = newDate;
      }}
    />
  );
});

Still Not Working?

Component renders but buttons/inputs are not interactive — Qwik lazy-loads event handlers. If the JavaScript chunk fails to load (network error, incorrect base URL), interactions silently fail. Check the browser Network tab for failed chunk requests. Also verify that onClick$ uses the $ suffix — onClick (without $) binds a regular handler that won’t be serialized and won’t work after SSR.

useStore changes don’t trigger re-renders for nested objectsuseStore is deeply reactive by default, but replacing a nested object breaks the proxy chain. Use state.nested.property = newValue (mutate) instead of state.nested = { ...state.nested, property: newValue } (replace). For arrays, push, splice, and index assignment work. Direct reassignment of the array (state.items = newArray) also works.

routeLoader$ data is stale after client-side navigationrouteLoader$ re-runs on each navigation by default. If the data appears stale, the issue is usually caching at the API level. Check if your API endpoint returns cached responses. For real-time data, use server$ with manual refresh instead of routeLoader$.

Build fails with “cannot capture” or “cannot serialize” — a non-serializable value is crossing a $ boundary. The error message usually names the offending value. Wrap it in noSerialize(), move it inside a useVisibleTask$, or restructure so it doesn’t cross the boundary.

For related framework issues, see Fix: SolidJS Not Working and Fix: SvelteKit Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles