npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jsfsi-core/ts-react

v1.0.11

Published

React utilities for building applications with hexagonal architecture

Readme

@jsfsi-core/ts-react

React utilities for building applications with hexagonal architecture — IoC container, error boundaries, authentication, theming, forms, and route protection.

Installation

npm install @jsfsi-core/ts-react

Peer Dependencies

npm install react react-dom inversify react-router-dom react-hook-form dequal firebase

firebase is only required if you use the bundled FirebaseClient. The package externalizes firebase/* at build time, so it stays out of your app bundle unless you actually import it.

Architecture

This package provides framework-level building blocks for React applications following hexagonal architecture:

  • Dependency Injection — inversify-based IoC container with React context
  • Error Handling — Error boundaries with crashlytics reporting
  • Authentication — Generic AuthProvider<TUser> + AuthenticationAdapter<TUser> + ready-made FirebaseClient
  • Theming — localStorage-persisted theme with system preference detection
  • Forms — react-hook-form wrapper with auto-reset
  • Route Protection — Authenticated route guards

No styling opinions — this package intentionally excludes CSS, Tailwind utilities, and styled components. Consuming apps provide their own UI via props.

Features

IoC Container

Inversify-based dependency injection for React components:

import { IoCContextProvider, useInjection, BindingType } from '@jsfsi-core/ts-react';

// Define bindings
const bindings: readonly BindingType<unknown>[] = [
  { type: AuthService, instance: new AuthService() },
  { type: UserService, dynamicValue: (ctx) => new UserService(ctx.get(AuthService)) },
];

// Provide container
function App() {
  return (
    <IoCContextProvider bindings={bindings}>
      <MyComponent />
    </IoCContextProvider>
  );
}

// Consume dependencies
function MyComponent() {
  const userService = useInjection(UserService);
  // ...
}

Error Boundary & Crashlytics

Error boundary with crash reporting context:

import { CrashlyticsProvider, useCrashlytics } from '@jsfsi-core/ts-react';

function ErrorPage({ error }: { error: Error | null }) {
  return <div>Something went wrong: {error?.message}</div>;
}

function App() {
  return (
    <CrashlyticsProvider fallback={ErrorPage}>
      <MyApp />
    </CrashlyticsProvider>
  );
}

// Report errors in components
function MyComponent() {
  const { reportFailure } = useCrashlytics();

  const handleError = (failure: unknown) => {
    reportFailure('Operation failed', failure);
  };
}

Authentication

The package ships three layers you can pick from:

  1. AuthProvider<TUser> / useAuth<TUser>() — generic context that wraps any AuthService<TUser> and manages loading + currentUser.
  2. AuthenticationAdapter<TUser extends User> — default AuthService<TUser> implementation that delegates to an injected AuthClient<TUser>.
  3. FirebaseClient — default AuthClient<User> backed by firebase/compat/auth.

Use them together, or replace any layer with your own.

Typical wiring

AuthProvider has no dependency on your IoC container — it receives behavior through callback props. The consuming app resolves its domain service one level up and forwards each method through a callback. This keeps the library boundary clean (it never reaches into your container) and keeps domain logic where it belongs.

Prefer composition over inheritance for the domain service. It implements AuthService<User> and receives an adapter through the constructor, so you can add business rules (extra validation, logging, feature flags) without touching the adapter.

import {
  AuthenticationAdapter,
  AuthProvider,
  FirebaseClient,
  IoCContextProvider,
  useInjection,
  type AuthService,
  type EmailPasswordCredentials,
  type User,
} from '@jsfsi-core/ts-react';

// Domain service — composes the adapter, does not extend it.
export class AuthenticationService implements AuthService<User> {
  constructor(private readonly authenticationAdapter: AuthenticationAdapter<User>) {}

  onAuthStateChanged(cb: (u: User | null) => void) { return this.authenticationAdapter.onAuthStateChanged(cb); }
  signOut()                                         { return this.authenticationAdapter.signOut(); }
  signIn()                                          { return this.authenticationAdapter.signIn(); }
  signInWithEmailAndPassword(c: EmailPasswordCredentials) { return this.authenticationAdapter.signInWithEmailAndPassword(c); }
  signUp()                                          { return this.authenticationAdapter.signUp(); }
  signUpWithEmailAndPassword(c: EmailPasswordCredentials) { return this.authenticationAdapter.signUpWithEmailAndPassword(c); }
  sendPasswordResetEmail(email: string)             { return this.authenticationAdapter.sendPasswordResetEmail(email); }
}

const bindings = [
  {
    type: FirebaseClient,
    instance: new FirebaseClient(firebaseConfig).initialize(),
  },
  {
    type: AuthenticationAdapter,
    dynamicValue: (ctx) => new AuthenticationAdapter<User>(ctx.get(FirebaseClient)),
  },
  {
    type: AuthenticationService,
    dynamicValue: (ctx) =>
      new AuthenticationService(ctx.get<AuthenticationAdapter<User>>(AuthenticationAdapter)),
  },
];

// Wire the service to the provider. `AppAuthProvider` lives in your app layer
// and is the only place that knows both the IoC container and AuthProvider.
function AppAuthProvider({ children }: { children: React.ReactNode }) {
  const service = useInjection(AuthenticationService);

  return (
    <AuthProvider<User>
      loader={FullscreenLoader}
      onAuthChanged={(cb) => service.onAuthStateChanged(cb)}
      onSignIn={() => service.signIn()}
      onSignOut={() => service.signOut()}
      onSignInWithEmailAndPassword={(c) => service.signInWithEmailAndPassword(c)}
      onSignUp={() => service.signUp()}
      onSignUpWithEmailAndPassword={(c) => service.signUpWithEmailAndPassword(c)}
      onSendPasswordResetEmail={(email) => service.sendPasswordResetEmail(email)}
    >
      {children}
    </AuthProvider>
  );
}

function App() {
  return (
    <IoCContextProvider bindings={bindings}>
      <AppAuthProvider>
        <Routes />
      </AppAuthProvider>
    </IoCContextProvider>
  );
}

Four layers compose top-down: app callbacks (wire IoC to the provider) → AuthenticationService (domain) → AuthenticationAdapter<User> (port adapter) → FirebaseClient (edge). AuthProvider only knows about the callbacks.

Callback props are held in refs internally, so you don't need to wrap them in useMemo/useCallback — passing inline arrows on every render is fine and does not re-subscribe to onAuthChanged.

AuthProvider renders <Loader /> in place of its children while loading is true (initial mount and during any wrapped call). Each method is wrapped with try/finally, so loading resets even if the underlying call throws. The context value is memoized, so unrelated re-renders of the provider don't cascade to consumers.

Consuming the context

import { useAuth } from '@jsfsi-core/ts-react';
import { isFailure } from '@jsfsi-core/ts-crossplatform';
import { SignInFailure } from '@jsfsi-core/ts-react';

function LoginForm() {
  const { signInWithEmailAndPassword, loading } = useAuth<User>();

  const handleSubmit = async (email: string, password: string) => {
    const [user, failure] = await signInWithEmailAndPassword({ email, password });

    if (isFailure(SignInFailure)(failure)) {
      toast.error(t('login.errors.failed'));
      return;
    }

    navigate('/dashboard');
  };

  // ...
}

Custom AuthClient

Skip FirebaseClient entirely and plug in your own provider (Auth0, Supabase, etc.) by implementing AuthClient<TUser>:

import type { AuthClient } from '@jsfsi-core/ts-react';

class MyAuthClient implements AuthClient<MyUser> {
  onAuthStateChanged(callback) { /* ... */ }
  signOut() { /* ... */ }
  signInWithGoogle() { /* ... */ }
  signInWithEmailAndPassword(credentials) { /* ... */ }
  createUserWithEmailAndPassword(credentials) { /* ... */ }
  sendPasswordResetEmail(email) { /* ... */ }
}

Then bind MyAuthClient in place of FirebaseClientAuthenticationAdapter is agnostic to the underlying provider.

Failures

All auth methods return Result<T, Failure> from @jsfsi-core/ts-crossplatform. Discriminate with isFailure():

  • SignInFailure — sign-in (Google or email/password) failed
  • SignUpFailure — email/password sign-up failed
  • PasswordResetEmailFailure — password reset request failed

Authenticated HTTP client (Firebase)

FirebaseAuthenticatedHttpClient extends HttpSafeClient (from @jsfsi-core/ts-crossplatform) and injects the signed-in user's Firebase id token as a Bearer token.

The name is intentionally explicit: this class is coupled to FirebaseClient, so Firebase appears in the type name. Consumers using a different auth provider (Auth0, Supabase, etc.) should not use this class — extend HttpSafeClient directly and return your own Authorization header from getHeaders().

Bind it in your IoC container alongside FirebaseClient, extend it in a domain adapter, then resolve that adapter via useInjection inside a component. Do not hardcode baseUrl inside the class — pass it through the constructor (the constructor signature enforces this) so tests can point at a mock server.

import { FirebaseAuthenticatedHttpClient, FirebaseClient, useInjection } from '@jsfsi-core/ts-react';

// 1. Bind in AppBindings — baseUrl comes from app config via dynamicValue.
const bindings = [
  {
    type: FirebaseClient,
    instance: new FirebaseClient(firebaseConfig).initialize(),
  },
  {
    type: FirebaseAuthenticatedHttpClient,
    dynamicValue: (ctx) =>
      new FirebaseAuthenticatedHttpClient(ctx.get(FirebaseClient), configuration.VITE_API_URL),
  },
  {
    type: UsersAdapter,
    dynamicValue: (ctx) => new UsersAdapter(ctx.get(FirebaseAuthenticatedHttpClient)),
  },
];

// 2. Extend (or compose) it in a domain adapter. UserSchema and UserFailure are
//    placeholders the reader supplies.
export class UsersAdapter {
  constructor(private readonly httpClient: FirebaseAuthenticatedHttpClient) {}

  getCurrentUser() {
    return this.httpClient.fetch('/users/me', UserSchema, UserFailure, { method: 'GET' });
  }
}

// 3. Resolve the adapter via useInjection inside a component.
function CurrentUser() {
  const usersAdapter = useInjection(UsersAdapter);
  // ...
}

See @jsfsi-core/ts-crossplatform docs for the HttpSafeClient API (fetch, fetchBlob, failure types).

Theme Provider

localStorage-persisted theme with system preference detection:

import { ThemeProvider, useTheme } from '@jsfsi-core/ts-react';

function App() {
  return (
    <ThemeProvider defaultTheme="system" storageKey="app-theme">
      <MyApp />
    </ThemeProvider>
  );
}

function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>Toggle</button>;
}

Form

react-hook-form wrapper with auto-reset on defaultValues change:

import { Form } from '@jsfsi-core/ts-react';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({ name: z.string().min(1) });

function MyForm({ defaultValues }: { defaultValues: { name: string } }) {
  return (
    <Form
      resolver={zodResolver(schema)}
      defaultValues={defaultValues}
      onSubmit={(data) => console.log(data)}
    >
      {/* Form fields using react-hook-form's useFormContext */}
    </Form>
  );
}

Hooks

useService

Async data-fetching hook that exposes { data, fetching, refetch } and rethrows caught errors from render so they can be caught by ErrorBoundary.

import { useService } from '@jsfsi-core/ts-react';

function TenantsList() {
  const tenantsService = useInjection(TenantsService);
  const { data, fetching, refetch } = useService(
    { service: () => tenantsService.list() },
    [],
  );

  if (fetching) return <Spinner />;
  return <ul>{data?.map((t) => <li key={t.id}>{t.displayName}</li>)}</ul>;
}

Pass staleData: true to keep the previous data visible during a refetch instead of clearing it.

useDebounce

Returns a stable debounced function that only fires delay ms after the last call.

import { useDebounce } from '@jsfsi-core/ts-react';

function SearchBox() {
  const searchService = useInjection(SearchService);
  const debouncedSearch = useDebounce((q: string) => searchService.search(q), 300);

  return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}

useIsMobile

Returns true when the viewport is under 768 px; listens to matchMedia('(max-width: 767px)') and updates on resize.

import { useIsMobile } from '@jsfsi-core/ts-react';

function Nav() {
  const isMobile = useIsMobile();
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

Protected Route (Factory Pattern)

Create route guards tied to your auth context:

import { createProtectedRoute } from '@jsfsi-core/ts-react';
import { useAuth } from './auth';

export const ProtectedRoute = createProtectedRoute(useAuth);

// Usage in router
<Route path="/dashboard" element={
  <ProtectedRoute redirectTo="/login" loader={LoadingSpinner}>
    <DashboardPage />
  </ProtectedRoute>
} />

Security

  • User.idToken is held in React state and accessible to any script running on the page. Consuming apps MUST enforce a strict Content Security Policy and treat any XSS as a token-exfiltration incident. Never persist idToken in localStorage or sessionStorage.
  • FirebaseClient uses firebase/compat and signInWithPopup. Popup blockers, strict COOP/COEP headers, or embedded contexts (iframes) can break the flow — fall back to a redirect-based AuthClient if you need to support those.

Testing

Components from this package can be tested using standard React Testing Library patterns. Override IoC bindings for tests:

import { IoCContextProvider, BindingType } from '@jsfsi-core/ts-react';
import { mock } from '@jsfsi-core/ts-crossplatform';

const testBindings: readonly BindingType<unknown>[] = [
  { type: MyService, instance: mock<MyService>({ getData: vi.fn() }) },
];

render(
  <IoCContextProvider bindings={testBindings}>
    <MyComponent />
  </IoCContextProvider>
);

For apps that register a fixed list of bindings at the root (e.g. AppBindings), use createBindingsOverrides to produce a test helper that swaps selected bindings while keeping the rest intact:

// test/app-bindings-overrides.ts
import { createBindingsOverrides } from '@jsfsi-core/ts-react';
import { AppBindings } from '../src/ui/app/AppBindings';

export const AppBindingsOverrides = createBindingsOverrides(AppBindings);
// MyComponent.test.tsx
import { mock, Ok } from '@jsfsi-core/ts-crossplatform';
import { AppBindingsOverrides } from '../../test/app-bindings-overrides';
import { AppProviders } from './AppProviders';

render(
  <AppProviders
    bindings={AppBindingsOverrides({
      overrides: [
        { type: MyService, dynamicValue: () => mock<MyService>({ getData: vi.fn() }) },
      ],
    })}
  >
    <MyComponent />
  </AppProviders>,
);

License

ISC