RFC: Reject unknown top-level props in spring hooks
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 29.1k
- Forks
- 1.2k
- PR merge metrics
- No merged PRs in 30d
Description
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?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading types/common.ts and the current useSpring type signature, then trace the deps and function-props overloads plus the related useTransition surface. Done means unknown top-level control keys are rejected while arbitrary animation values remain valid inside from, to, and update, with existing overload behavior preserved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- developer-experience, frontend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100