RFC: Reject unknown top-level props in spring hooks
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 ("squareRefdoes not exist onUseSpringProps"){ confg: { duration: 200 } }(typo) → error{ onRset: ... }→ error{ to: { x: 100 }, x: 100 }(animation value at top level instead of into) → error
What this preserves
useSpring({ from: { wibble: 0 }, to: { wibble: 100 } })— still fine,wibbleis insidefrom/to.- The function form
useSpring((i, ctrl) => ({ ... })). - The deps array.
Drawbacks
Breaking type-only change. Code that today smuggles arbitrary keys at the top level (e.g. relying on
from-less shorthanduseSpring({ x: 0 })to set the goal) will now error.Mitigation: this pattern is already discouraged in the docs —
from/toare the canonical entry points. Provide a codemod for the obvious shape ({ x: 0, y: 0 } → { to: { x: 0, y: 0 } }).useTransitionitems. Transitions accept{ from, enter, leave, update, ... }plus arbitrary control props. Same split applies but the surface is bigger; needs auditing per-hook.Pre-existing
Valid<>machinery. Still useful for the inferred-state cases (function-props, whereStatecomes 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
refprop. Makerefa 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
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.Codemod scope — community packages and example snippets all over the place. How aggressive should the migration tooling be?
Should the same treatment extend to
Controller.start(props)andSpringValue.start(props)for symmetry?
Source: pmndrs/react-spring