RFC: Reject unknown top-level props in spring hooks

Author: joshuaellisCreated May 23, 2026Updated Jun 23, 2026
Labelstype: RFCrelease: major

Summary

The public types for the spring hooks silently accept properties that aren't part of the API. This is the root cause of #2198 (and likely an unknown number of other "useChain doesn't work" / "delay isn't applied" reports): users write useSpring({ squareRef, ... }) instead of useSpring({ ref: squareRef, ... }) and get no compile-time signal. The spring then auto-starts (no ref ⇒ no imperative gating), which looks like a runtime bug but is a silently-dropped prop.

This RFC proposes tightening the prop types to flag unknown keys at the top level while preserving arbitrary animatable values inside from / to / update / etc.

Motivation

A minimal repro that compiles clean today:

typescript
const ref = useSpringRef()
useSpring({
  squareRef: ref,       // ← typo, no error
  randomKey: 'hello',   // ← also no error
  from: { x: 0 },
  to: { x: 100 },
})

Why this slips through: useSpring's signature is

typescript
useSpring<Props extends object>(
  props: (Props & Valid<Props, UseSpringProps<Props>>) | UseSpringProps
): SpringValues<PickAnimated<Props>>

Props is inferred from the call site, so any extra key becomes part of Props itself. Valid<T, U> (in types/common.ts) intersects keyof T & keyof U and only flags keys whose types disagree — it has no concept of "keys in T that aren't in U".

The result: typos in control props (ref, config, delay, immediate, loop, reset, reverse, onStart, onRest, …) and in shorthand fields silently no-op.

Detailed design

Split the prop surface into two distinct shapes:

typescript
type ControlProps<State> = {
  ref?: SpringRef<State>
  config?: SpringConfig | ((key: keyof State) => SpringConfig)
  delay?: number | ((key: keyof State) => number)
  immediate?: boolean | ((key: keyof State) => boolean)
  loop?: LoopProp<State>
  pause?: boolean
  cancel?: boolean | string | string[] | ((key: keyof State) => boolean)
  reset?: boolean | ((key: keyof State) => boolean)
  reverse?: boolean
  default?: boolean | Partial<ControlProps<State>>
  // ...lifecycle: onStart, onChange, onRest, onPause, onResume, onResolve
  // ...sources: from, to, update
}

type AnimatableValues<State extends Lookup> = State // open shape; the values.

type UseSpringProps<State extends Lookup = Lookup> = ControlProps<State> & {
  from?: Partial<AnimatableValues<State>>
  to?: GoalValues<State>  // single | array | async fn — current shape
  update?: Partial<AnimatableValues<State>>
}

Then the public hook signature becomes a strict object type (no Props extends object inference seam at the top level):

typescript
function useSpring<State extends Lookup>(
  props: UseSpringProps<State>
): SpringValues<State>

with the conventional overloads for the deps / function-props variants.

Excess-property checking now applies because the object literal is checked directly against a fixed type, not against an inferred mirror of itself. Animation values live inside from / to / update, where the open shape is needed and harmless.

What this catches

  • { squareRef: ref, ... } → error ("squareRef does not exist on UseSpringProps")
  • { confg: { duration: 200 } } (typo) → error
  • { onRset: ... } → error
  • { to: { x: 100 }, x: 100 } (animation value at top level instead of in to) → error

What this preserves

  • useSpring({ from: { wibble: 0 }, to: { wibble: 100 } }) — still fine, wibble is inside from/to.
  • The function form useSpring((i, ctrl) => ({ ... })).
  • The deps array.

Drawbacks

  1. Breaking type-only change. Code that today smuggles arbitrary keys at the top level (e.g. relying on from-less shorthand useSpring({ x: 0 }) to set the goal) will now error.

    Mitigation: this pattern is already discouraged in the docs — from / to are the canonical entry points. Provide a codemod for the obvious shape ({ x: 0, y: 0 } → { to: { x: 0, y: 0 } }).

  2. useTransition items. Transitions accept { from, enter, leave, update, ... } plus arbitrary control props. Same split applies but the surface is bigger; needs auditing per-hook.

  3. Pre-existing Valid<> machinery. Still useful for the inferred-state cases (function-props, where State comes from the return type). Keep it for that path.

Alternatives

  • Status quo + lint rule. Ship an ESLint rule that flags unknown keys in spring hook call sites. Lower ergonomic cost but doesn't help users without ESLint or with non-literal call sites.

  • Runtime warn in dev. Add a process.env.NODE_ENV !== 'production' check that warns on unknown keys at the controller layer. Cheap to ship, catches it eventually, but the failure mode is still "your animation silently doesn't sequence" rather than a red squiggle.

  • Brand the ref prop. Make ref a required prop in a sub-type guarded by a brand to force users to spell it. Too invasive, breaks ergonomic shorthand for non-chained uses.

Open questions

  1. Is the function-props overload (useSpring((i, ctrl) => props)) reachable from this tightening without regression? The return type of the function would still need the same strict shape.

  2. Codemod scope — community packages and example snippets all over the place. How aggressive should the migration tooling be?

  3. Should the same treatment extend to Controller.start(props) and SpringValue.start(props) for symmetry?