S-text-field onChange vs onInput for metafield state

Hi, Shopify dev community -

In our checkout extension I’m working to change to using “s-text-field” instead of the old “TextInput” component. Our inputs are controlled.

I’ve noticed one thing that wasn’t happening before:

  1. We have a metafield that’s a JSON with things like “{month: 9, day: 10}”
  2. We have two independent text fields that update different keys within the same metafield (e.g. an input for “month”, and an input for “date”)
  3. When tabbing from the “month” field to the “day” field, sometimes the input for the day “snaps back” to an empty string (presumably because the update from the “month” field has now finished)

Here’s a gif of it in practice, notice the “bbb” in the second input clears while i keep typing:

2026-06-11 16.24.44

A few questions:

  1. In the old checkout components, onChange was highly recommended over onInput. Is that guidance still the same? We could use onInput here to avoid the delay, but obviously that would add more re-renderes to the app.
  2. Any other work arounds? Ideally we only have one metafield JSON for our app data. I would hate to have different metafields for different sub-fields.
  3. Is this behavior expected? I only ask because prior to this upgrade everything “worked”

Thanks! And in cases it helps, here’s my minimal reproduction (very messy - sorry!) from the gif above:

const applyMetafieldsChange = useApplyMetafieldsChange();
  const [value] = useAppMetafields({ namespace: "$app", key: "myMetafield" });
  const parsedValue = value?.metafield.value
    ? JSON.parse(value?.metafield.value)
    : {};

  console.log("value", value?.metafield.value);
  return (
    <>
      <s-text-field
        label="Controlled component - metafield state"
        name="name"
        value={parsedValue?.a}
        autocomplete="off"
        onChange={(e) => {
          console.log("Would update state:", {
            ...parsedValue,
            a: e.currentTarget.value,
          });
          applyMetafieldsChange({
            type: "updateCartMetafield",
            metafield: {
              namespace: "$app",
              key: "myMetafield",
              value: JSON.stringify({
                ...parsedValue,
                a: e.currentTarget.value,
              }),
              type: "json",
            },
          });
        }}
      />

      <s-text-field
        label="Second controlled component - metafield state"
        name="name"
        value={parsedValue?.b}
        autoComplete="off"
        onChange={(e) => {
          console.log("Would update state 2:", {
            ...parsedValue,
            b: e.currentTarget.value,
          });
          applyMetafieldsChange({
            type: "updateCartMetafield",
            metafield: {
              namespace: "$app",
              key: "myMetafield",
              value: JSON.stringify({
                ...parsedValue,
                b: e.currentTarget.value,
              }),
              type: "json",
            },
          });
        }}
      />
    </>
  );

Hey @szafan - thanks for the detailed writeup.

I reproduced the snap-back with your setup: two controlled s-text-field inputs writing different keys of one JSON cart metafield, value bound to the useAppMetafields read, and the write firing on onChange. Tabbing from A to B and continuing to type clears B the moment A’s async write resolves.

What looks to be happening is that the input’s value is driven straight off the metafield read, so when that async value updates it re-applies to the fields and overwrites whatever you’ve typed. I’m digging in a bit deeper on the intended behaviour here, and on whether onChange or onInput is the recommended event for metafield-backed inputs, so I can give you a definitive answer.

I’ll follow up here once I’ve confirmed the intended behaviour. Hope this helps!

Sounds good - thanks! I think I’m just a bit surprised since we did not see this behavior happening prior to migrating off the old “TextInput” components.

Let me know what you find!

Hey @szafan - following up on this.

I did some digging here, and it does look like the snap-back is expected behaviour.

The cause is the one I suspected in my first reply: your inputs are controlled with value bound straight to the useAppMetafields read, and that read is async. Both fields write the same metafield, so when field A’s write resolves, both fields re-render off the freshly resolved value and anything you’ve typed into B that hasn’t been committed yet is overwritten. The stored metafield has no way to know about in-memory edits that haven’t been committed, so it can’t preserve them.

The recommended pattern is to back the fields with local Preact state and treat the metafield as the commit target, not the live source of truth for what’s being typed. Use your local state as each input’s value, then commit to the metafield with applyMetafieldsChange() - committing on blur rather than on every keystroke works well and keeps the write count down. That lets you keep your single JSON metafield design; you’re just committing all the in-memory values at once instead of binding each field directly to the async read.

Hope this helps!