Seeking guidance on design system package extraction and encapsulation with TanStack Router
I’m integrating Catalyst UI (Headless UI + TailwindCSS, docs) into a React app using TanStack Router, and trying to preserve both design system encapsulation and router-aware type safety goodies at the point of use.
Context
- I’m extracting our UI kit into a pnpm workspace package, which should remain agnostic to TanStack Router (and unaware of the router instance).
- The DS exposes Link-like components (e.g.
TextLink
, DropdownItem
, SidebarItem
) that share a base Link (forwardRef
'd HeadlessUI DataInteractive
-> <a>
).
- I want to support usage in the React app like <DropdownItemLink to="/profile" params={{ userId }} />
with proper type narrowing, while preserving DS styling and behavior (e.g. menu dismissal, button fallbacks, etc) of the inner <DropdownItem>
component
Design Tradeoff
I'm currently torn between:
A. Single createLink()
+ Context link injection + type coercion wrappers
- Use createLink()
once in the app and inject via context (LinkProvider
) into the DS.
- Wrap DS components at the app layer (e.g. DropdownItemLink
) just to preserve type safety at the callsite, but the implementation is a reference and prop drill
- Downside: feels awkward to drill to
, params
through the DS layers as it's a breach of contract. Coercion feels bad, even though it "works" at runtime.
B. N createLink()
wrappers
- Implement createLink(DropdownItem)
, createLink(SidebarItem)
, etc. individually.
- DS only deals in 'a'
, and all routing smarts live in the app layer.
- Pros: better aligns with Custom Link guide (from what I can tell), avoids drilling router props through DS layers that shouldn't know what those props are, keeps routing entirely a concern of the main app where it belongs
- Cons: boilerplate heavy and somewhat brittle, though about as brittle as A and maybe there's a clever hook or something I could use to DRY it up.
C. Something simpler I’m missing?
- Neither A nor B feels especially satisfying, though B feels like the winner of the two.
- It seems like there ought to be a way to centralize, but I'm currently failing to see it. I'm definitely feeling a bit turned around with all the ref forwarding, abstraction, and indirection at the moment.
- Would appreciate any insight or direction — happy to share minimal repros if helpful!
Thanks!5 Replies
quickest-silver•4mo ago
I think B is the far better way to go, it'll keep your design system agnostic re router usage. I assume you had to extract it out for other apps that potentially might not use Tanstack Router or just would be annoying to couple different Router instances to the DS.
It'll be that you essentially in your Router apps will have something like
src/components/links.tsx
that imports and connects all the links I think.exotic-emeraldOP•4mo ago
thanks for the response! I dug in a bit more and my current thinking is that I actually need to do a hybrid of A and B to get all the desired behavior.
As I see it, TanStack wants to wrap (with
createLink()
) -- that's how you get the nice router-aware prop interface when consuming in the router app. The design system wants to inject -- it doesn't necessarily guarantee an unbroken chain of is-a relationships from outer exposed component -> inner <a>
.
if I go with strict option B, I would be downsampling an enriched tanstack Link
into a native <a>
at the DS package boundary which is a lossy transformation. The <a>
wouldn't get the onClick behavior it needs to avoid hard browser navigations, that onClick behavior would live on the wrapped outer element.
what I ended up doing was using the LinkProvider
to inject a "pure" (unaware of registered app router) tanstack Link
to the DS, and then set up the N shims in the router app to get the typechecking at call sites. I suspect I'm masking some brittleness with this setup, but could probably solve that by enhancing the provider to inject the "right" enhanced link at the right time (rather than a generic one), but it's working well enough for now it seems.extended-salmon•4mo ago
we also have these type utilities
https://tanstack.com/router/latest/docs/framework/react/guide/type-utilities#type-checking-link-options-with-validatelinkoptions
which you can use for building your own link components
exotic-emeraldOP•4mo ago
nice, this could be the missing piece. I think I just need Link-like typechecking on the outside, which makes
createLink()
probably overkill. Then I can inject the actual compnent for event handling. I'll give it a go and report back any findings.passive-yellow•5w ago
@collin Hey Collin, I'd love to know how it went as I'm facing the same dilemma ^^