Skip to content

Require input on transitions targeting states with an input schema - #5601

Open
asfktz wants to merge 14 commits into
statelyai:nextfrom
asfktz:feat/require-input-on-transitions
Open

Require input on transitions targeting states with an input schema#5601
asfktz wants to merge 14 commits into
statelyai:nextfrom
asfktz:feat/require-input-on-transitions

Conversation

@asfktz

@asfktz asfktz commented Jul 8, 2026

Copy link
Copy Markdown

Transitions that target a state declaring schemas.input now require an input property, enforced at the type level.
This applies to on, always, after, onTimeout, onDone, onError, invoke handlers, and initial.

const machine = setup({
  states: {
    loading: {
      schemas: { input: z.object({ userId: z.string() }) }
    }
  }
}).createMachine({
  initial: 'idle',
  states: {
    idle: {
      on: {
        // Before: LOAD: { target: 'loading' }  — now a type error
        LOAD: { target: 'loading', input: { userId: 'user-1' } }
      }
    },
    loading: {}
  }
});

Special Cases

Self-transitions with input

When a state transitions to itself without reenter: true, it’s considered an internal transition.

However, the transition type is not aware of the current state it’s transitioning from, so there is no way to tell that we shouldn’t require input for this case.

I experimented with extending the types, but ultimately decided not to add complexity for this rare case.

However, at runtime, I ensured input is ignored, so only external transitions resolve input.

active: {
  on: {
  // re-entering self-transition: input is re-resolved to 10
    setMultiplier: { 
    	reenter: true, 
	    target: 'active', 
	    input: { multiplier: 10 }
	  }
	  
    // internal self-transition without reenter
		// input is dropped, resolved input stays whatever it was on entry
    setMultiplier: {
	    target: 'active',
	    input: { multiplier: 99 }
	  },
  }
}

Unhandled cases / open questions

Targeting a state by #id

Since IDs are defined in createMachine (not setup), types can’t infer them by reading setup alone.
Unfortunately, this case is not covered.

const def = setup({
	states: {
	   idle: {},
	   active: {
		   schemas: {
		     input: z.object({ prop: z.string() })
		   }
	   }
	}
})

const idleStateConfig = def.createStateConfig('idle', {
	on: {
		activate: {
			target: '#active'
			// how can we tell if a state by id "#active" requires input or not?
		}
	}
})

const activeStateConfig = def.createStateConfig('active', {
	id: '#active'
})

def.createMachine({
	initial: 'idle',
	states: {
		idle: idleStateConfig,
		active: {
			id: 'active' // defined here, not in setup
		}
	}
})

Parallel states

How do you pass input to multiple regions in a parallel state?

const def = setup({
  states: {
    editor: {
      // becomes `type: 'parallel'` in createMachine
      states: {
        document: {
          states: {
            viewing: {
              schemas: { input: z.object({ docId: z.string() }) }
            }
          }
        },
        toolbar: {
          states: {
            visible: {
              schemas: { input: z.object({ layout: z.string() }) }
            }
        }
      }
    },
    idle: {}
  }
});

def.createMachine({
  initial: 'idle',
  states: {
    idle: {
      on: {
        // Entering 'editor' activates BOTH regions at once.
        // Each region's initial state requires its own input,
        // but a transition only carries a single `input`.
        // Which region does it belong to? How do we provide both?
        OPEN: { target: 'editor', input: /* ??? */ }
      }
    },
    editor: {
      type: 'parallel',
      states: {
        document: { initial: 'viewing', states: { viewing: {} } },
        toolbar: { initial: 'visible', states: { visible: {} } }
      }
    }
  }
});

Transition to nested child (parent.child)

What should happen if both the parent and child require input?

const def = setup({
  states: {
    idle: {},
    parent: {
      schemas: {
        input: z.object({ parentProp: z.string() }),
      },
      states: {
        child: {
          schemas: {
            input: z.object({ childProp: z.string() }),
          },
        },
      },
    },
  },
});

def.createMachine({
  initial: "idle",
  states: {
    idle: {
      on: {
        next: () => ({
          target: "parent.child",
          input: { childProp: "value" },
        }),
      },
    },
    parent: {
      entry: ({ input }) => {
        console.log(`expects ${input.parentProp}`);
      },

      initial: { target: "child", input: { childProp: "..." } },
      states: {
        child: {
          entry: ({ input }) => {
            console.log(`expects ${input.childProp}`);
          },
        },
      },
    },
  },
});

In that case, it may be worth constraining both at runtime and by types so entering a sub-state isn’t allowed if its parent requires input.

idle: {
  on: {
    next: () => ({
      target: "parent", // allowed
      input: { parentProp: "value" },
    }),
    next: () => ({
      target: "parent.child", // rejected
      input: { childProp: "value" },
    }),
  },
},

asfktz added 14 commits July 6, 2026 18:44
Type-level enforcement: transitions targeting a state that declares
schemas.input now require the input property. Covers on, always, after,
onTimeout, onDone, onError, invoke handlers, function-syntax returns, and
object-form initial.

- StateTransitionResult: input conditionally required in the sibling branch
- StateNodeConfigWithNestedInput: route after/onError/onTimeout through the
  transition type so they carry (and require) input
- InitialTransitionWithInput: input conditionally required

Out of scope (input stays optional): #id, .child, array/parallel targets,
and bare-string initial shorthand.
Runtime backstop for the required-input feature: when input is provided on
a transition whose target is not actually being (re)entered (classic case:
a self-transition without reenter: true), the input is now dropped instead
of silently stored, and a dev-only console.warn explains why and how to fix
it (add reenter: true).

Guards the stateInputMap storage loop in microstep with
statesToEnter.has(targetNode), keying off the real computed entry set rather
than a hand-rolled predicate, so default-entry descendants of compound and
parallel targets are handled correctly. Complements statelyai#5597: prevents a
non-entering self-transition from clobbering _stateInputs, which the
timeout/onTimeout/after/event handlers now read.
A self-transition to a state that declares `schemas.input` now requires
`input` only when the state is actually re-entered (`reenter: true`, or a
dynamic `reenter`). Without `reenter` the target is not re-entered and any
provided input is dropped at runtime, so `input` is optional there. Cross-
target and no-schema transitions are unchanged.

Threads the source state's own sibling key as `TSelfKey` from the
`StatesWithInput` mapper down to `StateTransitionResult`, where a new
`TransitionInputRequirement` helper discriminates the `input` requirement on
`reenter` for the self key. This removes a contradiction with the runtime
backstop, which previously warned that the very input the type layer required
was being ignored.
Removes the TSelfKey type parameter and the self-transition-without-reenter
detection it enabled. TransitionInputRequirement now applies one uniform rule:
a transition to a state declaring schemas.input requires `input`, whether the
target is the source state itself or a different sibling.

This deletes ~24 pass-through sites that threaded TSelfKey from StatesWithInput
down to StateTransitionResult. The cost is that a self-transition without
reenter now requires `input` that the runtime still silently drops (the target
is not re-entered); add `reenter: true` to actually apply it.
Merge three overlapping tests into one that threads input behavior
through a single machine via count arithmetic: internal self-transitions
keep the resolved input, reentering self-transitions replace it, and
transition handlers read the current value throughout.
@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b8b203b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
xstate Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@davidkpiano

Copy link
Copy Markdown
Member

Quick thoughts:

  • Not sure what to do about self-transitions with input yet
  • In the future we can just hard-fail on missing input here at runtime at the very least
  • Re: parallel states, maybe we should make input only available to top-level states first
  • See above

@asfktz

asfktz commented Jul 9, 2026

Copy link
Copy Markdown
Author

Not sure what to do about self-transitions with input yet.

How about throwing a warning at runtime that the input is ignored for internal transitions and hint the user to add reenter: true?

In the future we can just hard-fail on missing input here at runtime at the very least.

For IDs? Yes, I agree.

Re: parallel states, maybe we should make input only available to top-level states first

Makes sense. The alternative would be introducing a new transition syntax like (and very roughly):

{ 
  target: 'state', 
  regions: { 
    a: { input: {} }, 
    b: { input: {} } 
  }
}

However, that requires careful design to get right, and I agree it's better not to block on it (tbh, I've never been a fan of parallel states personally).

Regarding transitioning to nested states (parent.child) with inputs, I was thinking of handling it in a separate PR.
I can enforce this both with types and on runtime while adding support for { target: 'parent.child' } autocomplete in general.

But if we limit the input to top-level states only, this becomes unnecessary, of course.

@davidkpiano

Copy link
Copy Markdown
Member

Thinking a little outside the box, we could provide one or more of:

  • TS plugin
  • Custom ESLint rules
  • CLI tool for static validation (similar to what @xstate/cli did in the past)

And the TS blind spots (like self-transitions, parallel states maybe, states with IDs, nested states, etc.) could be handled with those (optionally) and hard-fail in runtime.

@Andarist Would love your thoughts

@asfktz

asfktz commented Jul 9, 2026

Copy link
Copy Markdown
Author

Thinking a little outside the box, we could provide one or more of:

TS plugin
Custom ESLint rules
CLI tool for static validation (similar to what @xstate/cli did in the past)
And the TS blind spots (like self-transitions, parallel states maybe, states with IDs, nested states, etc.) could be handled with those (optionally) and hard-fail in runtime.

I think the challenge is that one setup can be used for many machine configurations.

So in theory this is possible:

// one setup

export const def = setup({
  schemas: {
    events: {
      next: z.object({}),
    },
  },
  states: {
    stateA: {},
    stateB: {
      schemas: {
        input: z.object({ propB: z.string() }),
      },
    },
    stateC: {
      schemas: {
        input: z.object({ propC: z.string() }),
      },
    },
  },
});

const stateA = def.createStateConfig("stateA", {
  on: {
    next: () => {
      return {
        target: "#id",
        input: { ...? },
        // Should the static analysis tool resolve
        // input to { propB: string } or { propC: string }?
      };
    },
  },
});

// multiple machine configurations

const machineA = def.createMachine({
  initial: "idle",
  states: {
    stateA: stateA,
    stateB: { id: "id" }, // 👈
    stateC: {},
  },
});

const machineB = def.createMachine({
  initial: "idle",
  states: {
    stateA: stateA,
    stateB: {},
    stateC: { id: "id" }, // 👈
  },
});

Maybe the simplest solution would be to move the source of truth to setup? Ideally without breaking typeless usages (using createMachine directly without a setup phase).

For example, what if we allow specifying IDs in setup?

export const def = setup({
  schemas: {
    events: {
      next: z.object({}),
    },
  },
  states: {
    stateA: {},
    stateB: {
      id: "id",
      schemas: {
        input: z.object({ propB: z.string() }),
      },
    },
    stateC: {
      schemas: {
        input: z.object({ propC: z.string() }),
      },
    },
  },
});

const stateA = def.createStateConfig("stateA", {
  on: {
    next: () => ({
      target: "#id",
      input: { propB: "value" },
      // Transition by ID inferred from setup — no ambiguity
    }),
  },
});

// multiple machine configurations

def.createMachine({
  initial: "stateA",
  states: {
    stateA: stateA,
    stateB: {
      id: "id", // must match
    },
    stateC: {},
  },
});

def.createMachine({
  initial: "stateA",
  states: {
    stateA: stateA,
    stateB: {}, // or omit (will default to the ID defined in setup)
    stateC: {},
  },
});

def.createMachine({
  initial: "stateA",
  states: {
    stateA: stateA,
    stateB: {},
    stateC: { id: "id" }, // wrong state — type error
  },
});

// typeless usage stays as-is
createMachine({
  initial: "stateA",
  states: {
    stateA: {
      on: {
        next: () => ({
          target: "#id",
          input: { propB: "value" },
        }),
      },
    },
    stateB: { id: "id" },
    stateC: {},
  },
});

We can do the same for parallel states by adding type to setup states:

export const def = setup({
  schemas: {
    events: {
      activate: z.object({}),
    },
  },
  states: {
    idle: {},
    active: {
      type: "parallel", // 👈
      states: {
        bold: {
          states: { off: {}, on: {} },
        },
        italic: {
          states: { off: {}, on: {} },
        },
        underline: {
          states: { off: {}, on: {} },
        },
      },
    },
  },
});

def.createMachine({
  initial: "idle",
  states: {
    idle: idle,
    active: {
      type: "parallel", // must match or omit
      states: {
        bold: {},
        italic: {},
        underline: {},
      },
    },
  },
});

Now, there are two cases I'm thinking about when transitioning to a parallel state with input:

  1. The parallel state's regions require input — namespace by region:
const idle = def.createStateConfig("idle", {
  on: {
    activate: () => ({
      target: "editing",
      input: {
        bold: { boldProp: "value" },
        italic: { italicProp: "value" },
        underline: { underlineProp: "value" },
      },
    }),
  },
});
  1. The parallel state itself requires input — pass input like a regular state:
const idle = def.createStateConfig("idle", {
  on: {
    activate: () => ({
      target: "editing",
      input: {
        prop: "value",
      },
    }),
  },
});

I think we should support only the second case (requiring input only on the parallel state itself), because it opens the door to a nice simplification. Here's what I mean:

When a state in setup has type: "parallel":

  • Its direct child states (the regions) no longer accept an input schema.
  • Instead, each region receives the parent's resolved input as its own.
export const def = setup({
  schemas: {
    events: {
      activate: z.object({}),
    },
  },
  states: {
    idle: {},
    editing: {
      type: "parallel", // 👈 by specifying the type, input schema is omitted from direct child states
      schemas: {
        input: z.object({ prop: z.string() }),
      },
      states: {
        bold: {
          schemas: {
             input: {}, // omitted; receives the parent input
          },
          states: {
            off: {
              schemas: {
                input: {}, // allowed
              },
            },
            on: {
              schemas: {
                input: {}, // allowed
              },
            },
          },
        },
        italic: {
          states: { off: {}, on: {} },
        },
        underline: {
          states: { off: {}, on: {} },
        },
      },
    },
  },
});

def.createMachine({
  idle: {
    on: {
      next: () => ({
        target: "editing",
        input: { prop: "value" },
      }),
    },
  },
  editing: {
    type: 'parallel',
    entry: ({ input }) => input.prop,
    states: {
      bold: {
        entry: ({ input }) => input.prop, // 👈 receives resolved parent input
      },
      italic: {
        entry: ({ input }) => input.prop,
      },
      underline: {
        entry: ({ input }) => input.prop,
      },
    },
  },
});

That way the whole problem dissolves — and we can also apply the same pattern to another problem: what if each parallel state region defines its own context schema and they're not compatible?

  • regionAcontext: { prop: string }
  • regionBcontext: { prop: number }

We can just omit context from regions so they accept their parallel parent state's context type instead.

@asfktz

asfktz commented Jul 29, 2026

Copy link
Copy Markdown
Author

@davidkpiano maybe it's worth merging the PR even if there are still some uncertainties around input typings for parallel states targets and by ID?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants