Skip to content

← all comparisons

Two-way binding

Quantity stepper

Bind an input to a store value, with deferred commit on blur/Enter so backspacing through the number does not delete the cart row.

Each panel header shows the source size of that component file (raw, gzipped, line count). Source-only — framework runtime (React ~45 KB, Vue ~35 KB, Svelte ~5 KB gzipped) is shared across every island and isn't included in these numbers.

React 718 B gzip · 55 lines
QuantityStepperReact.tsx
How this works

No native two-way binding. A local "draft" useState mirrors the store value, useEffect re-syncs when the external qty changes, and onBlur (or Enter) commits via setQty. The most code of the three because every wire is explicit.

import { useEffect, useState } from 'react';
import { useStore } from '@nanostores/react';
import { cart, setQty } from '../stores/cart';

type Props = { id: number };

export default function QuantityStepperReact({ id }: Props) {
  const $cart = useStore(cart);
  const qty = $cart[id]?.qty ?? 0;

  // Local draft so typing doesn't commit until blur/Enter.
  const [draft, setDraft] = useState(String(qty));
  useEffect(() => {
    setDraft(String(qty));
  }, [qty]);

  const commit = () => {
    const n = Number(draft);
    if (Number.isFinite(n) && n >= 0) setQty(id, n);
    else setDraft(String(qty));
  };

  return (
    <div className="inline-flex items-center rounded border border-slate-300 dark:border-slate-700">
      <button
        type="button"
        onClick={() => setQty(id, qty - 1)}
        className="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
        aria-label="Decrease"
      >

      </button>
      <input
        type="number"
        min={0}
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={commit}
        onKeyDown={(e) => {
          if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
        }}
        className="w-12 border-x border-slate-300 dark:border-slate-700 px-2 py-1 text-center tabular-nums focus:outline-none"
      />
      <button
        type="button"
        onClick={() => setQty(id, qty + 1)}
        className="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
        aria-label="Increase"
      >
        +
      </button>
    </div>
  );
}
Vue 534 B gzip · 38 lines
QuantityStepperVue.vue
How this works

v-model.lazy.number on a writable computed (with get/set). The .lazy modifier listens to the change event (which fires on blur for inputs) instead of input. The writable computed is the standard way to v-model derived state.

<script setup lang="ts">
import { computed } from 'vue';
import { useStore } from '@nanostores/vue';
import { cart, setQty } from '../stores/cart';

const props = defineProps<{ id: number }>();
const $cart = useStore(cart);

// Writable computed = the Vue idiom for v-model on derived state.
const qty = computed<number>({
  get: () => $cart.value[props.id]?.qty ?? 0,
  set: (v) => setQty(props.id, v),
});
</script>

<template>
  <div class="inline-flex items-center rounded border border-slate-300 dark:border-slate-700">
    <button
      type="button"
      @click="qty -= 1"
      class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
      aria-label="Decrease"
    >−</button>
    <input
      type="number"
      :min="0"
      v-model.lazy.number="qty"
      class="w-12 border-x border-slate-300 dark:border-slate-700 px-2 py-1 text-center tabular-nums focus:outline-none"
    />
    <button
      type="button"
      @click="qty += 1"
      class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
      aria-label="Increase"
    >+</button>
  </div>
</template>
Svelte 439 B gzip · 30 lines
QuantityStepperSvelte.svelte
How this works

Deliberately not using bind:value here — instead value={qty} + onchange. The change event already fires on blur, so we get the deferred commit for free without writing a getter/setter binding.

<script lang="ts">
  import { cart, setQty } from '../stores/cart';

  let { id }: { id: number } = $props();

  const qty = $derived($cart[id]?.qty ?? 0);
</script>

<div class="inline-flex items-center rounded border border-slate-300 dark:border-slate-700">
  <button
    type="button"
    onclick={() => setQty(id, qty - 1)}
    class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
    aria-label="Decrease"
  >−</button>
  <input
    type="number"
    min={0}
    value={qty}
    onchange={(e) => setQty(id, Number(e.currentTarget.value))}
    class="w-12 border-x border-slate-300 dark:border-slate-700 px-2 py-1 text-center tabular-nums focus:outline-none"
  />
  <button
    type="button"
    onclick={() => setQty(id, qty + 1)}
    class="px-2 py-1 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800"
    aria-label="Increase"
  >+</button>
</div>