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
-
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 } }).
-
useTransition items. 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, 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
-
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) and SpringValue.start(props) for symmetry?
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 ofuseSpring({ ref: squareRef, ... })and get no compile-time signal. The spring then auto-starts (noref⇒ 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:
Why this slips through:
useSpring's signature isPropsis inferred from the call site, so any extra key becomes part ofPropsitself.Valid<T, U>(intypes/common.ts) intersectskeyof T & keyof Uand only flags keys whose types disagree — it has no concept of "keys inTthat aren't inU".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:
Then the public hook signature becomes a strict object type (no
Props extends objectinference seam at the top level):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) → errorWhat this preserves
useSpring({ from: { wibble: 0 }, to: { wibble: 100 } })— still fine,wibbleis insidefrom/to.useSpring((i, ctrl) => ({ ... })).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?