Skip to content

RFC: Reject unknown top-level props in spring hooks #2522

@joshuaellis

Description

@joshuaellis

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:

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

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:

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):

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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: RFCRequest for comments

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions