DEV Community

Cover image for One treatment plan, three components: sharing state on a static Next.js site
Mirsad Zonic
Mirsad Zonic

Posted on AI-assisted

One treatment plan, three components: sharing state on a static Next.js site

Dental clinics that publish their prices let patients answer one question before they call: what will my treatment cost? While building Lumen, a dental clinic website template, we turned the public price list into a small tool. Every row is a toggle, the picked treatments add up to a plan, and that plan shows up in three places:

  • a tile in the hero with three featured treatments,
  • a sticky plan card next to the full price list,
  • the booking form on the contact page.

The site is a static export (output: 'export'), so there is no server session to keep the plan in. Here is how the three components share it, and what we would change on a bigger project.

1. A tiny store in one module

The plan is just a list of treatment names. It lives in localStorage, so it survives a reload or a patient opening the contact page directly, and every write fires a window event so all components on the page update together:

const KEY = 'dental-plan';
const EVENT = 'dental-plan-change';
let memory: string[] = [];

export function readPlan(): string[] {
  try {
    const v = JSON.parse(localStorage.getItem(KEY) || '[]');
    return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
  } catch {
    return memory;
  }
}

function writePlan(v: string[]) {
  memory = v;
  try { localStorage.setItem(KEY, JSON.stringify(v)); } catch {}
  window.dispatchEvent(new Event(EVENT));
}
Enter fullscreen mode Exit fullscreen mode

The try/catch pair matters. localStorage can throw when storage is blocked, and the in-memory copy keeps the plan working for the current visit.

2. One hook for every component

export function usePlan() {
  const [plan, setPlan] = useState<string[]>([]);
  useEffect(() => {
    const sync = () => setPlan(readPlan());
    sync();
    window.addEventListener(EVENT, sync);
    window.addEventListener('storage', sync);
    return () => {
      window.removeEventListener(EVENT, sync);
      window.removeEventListener('storage', sync);
    };
  }, []);
  const toggle = useCallback((name: string) => {
    const cur = readPlan();
    writePlan(cur.includes(name) ? cur.filter((x) => x !== name) : [...cur, name]);
  }, []);
  return { plan, toggle, total: planTotal(plan) };
}
Enter fullscreen mode Exit fullscreen mode

The custom event keeps components on the same page in sync: tick a row in the price list and the hero tile updates. The storage event does the same across tabs. toggle reads the stored plan instead of the state it closed over, so two components never write stale copies over each other.

The total is computed, never stored:

export const planTotal = (names: string[]) =>
  allItems.filter((i) => names.includes(i.name)).reduce((s, i) => s + i.price, 0);
Enter fullscreen mode Exit fullscreen mode

Because the plan holds names and not prices, changing a price in the content file changes the total of every saved plan too.

3. Start empty, fill after hydration

The first render starts with [] on purpose. The static HTML was built without a browser, so it can't know what the visitor picked last time. If the first client render read localStorage directly, it would not match the HTML and React would complain about a hydration mismatch. Starting empty and filling the plan in useEffect avoids that.

The "open today" tile uses the same idea. The build can't know the visitor's day and time, so the tile renders without a status, fills in after mount and re-checks once a minute.

4. An animated total that screen readers can follow

The total springs to its new value with Motion. Reading every in-between number aloud would be noise, so the animated number is hidden from assistive tech and a visually hidden live region announces only the final value:

export function SpringMoney({ value, className }: { value: number; className?: string }) {
  const reduce = useReducedMotion();
  const spring = useSpring(value, { stiffness: 170, damping: 18 });
  const [shown, setShown] = useState(value);
  useMotionValueEvent(spring, 'change', (v) => setShown(Math.max(0, Math.round(v))));
  useEffect(() => {
    if (reduce) { spring.jump(value); setShown(value); } else spring.set(value);
  }, [value, reduce, spring]);
  return (
    <span className={`tabular ${className ?? ''}`}>
      <span aria-hidden>{money(shown)}</span>
      <span className="sr-only" aria-live="polite">{money(value)}</span>
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

With prefers-reduced-motion the number jumps instead of bouncing. Price rows are plain <button aria-pressed> elements, so keyboard and screen reader users get a proper toggle.

5. Carrying the plan into the booking request

On the contact page the plan sits above the submit button as removable chips with the total. On submit it becomes one line of a plain-text request that opens in the visitor's mail app:

plan.length ? `${f.plan}: ${plan.join(', ')} (${money(total)})` : '',
Enter fullscreen mode Exit fullscreen mode

No API route and no form service. If a clinic wants submissions somewhere else, the mailto: line at the end of the submit handler is the only thing to swap for a fetch().

Trade-offs

  • useSyncExternalStore is React's official API for this. We kept the effect + event version because it is easy to read for buyers who are not React specialists. If you switch, cache the raw string in getSnapshot: returning a fresh array from JSON.parse on every call makes React re-render endlessly. Return an empty plan from getServerSnapshot for the static build.
  • The plan lives on one device. That's fine for a guide price; the dentist confirms the final plan after the check-up anyway.
  • Names are the keys. Rename a treatment in the content file and an old saved name no longer matches a price, so it stops counting toward the total (the patient can still remove it).

The template

Lumen is a bento-style dental clinic template: rounded pastel tiles, services with price chips, a tabbed price list, an FAQ and a bottom dock navigation on phones. It's a static Next.js App Router site with Tailwind CSS and Motion, and all texts, prices and opening hours live in one file.

Demo and download: https://market.designzonic.com/product/dental-lumen

Top comments (0)