Skip to content

Building blocks of any Frontend app

All you need to build a frontend app: Effect with platform and schema, XState for state management, TailwindCSS, and shadcn/ui using Radix UI and React Aria


Sandro Maglione

Sandro Maglione

Back to all issues
Software
2 min read
On this page

What is a frontend app made of in its essence? πŸ€”

I started noticing a pattern in the countless projects I worked on (big and small). These are the building blocks:

  • πŸ›œ Make external requests
  • πŸ•ΈοΈ Manage internal state
  • 🎨 Styling
  • 🧱 Components

Here is how I handle all of this in my apps πŸ‘‡


Make external requests

This can be API, local storage, IO. Anything that gets some resource from the outside.

The concerns here are:

  • Asynchronous code
  • Manage errors
  • Serialize/Deserialize requests
  • And others more specific (retry, scheduling, timeout, debugging)

I am full on Effect for all these concerns:

  • effect: Core for error handling, dependency injection, retries, and (way) more
  • @effect/schema: Serialize/Deserialize
  • @effect/platform: Http requests

Another library I use often is openapi-fetch. It allows to build a type-safe layer between any backend and your code.

Below and example of and Effect + openapi-fetch client:

Client.ts
import { Schema } from "@effect/schema";
import { Config, Context, Data, Effect, Layer } from "effect";
import createClient from "openapi-fetch";
import type { paths } from "./schema";

interface ClientConfig {
  readonly baseUrl: string;
}

export class ClientError extends Data.TaggedError("ClientError")<
  Readonly<{
    reason: "RequestError" | "EmptyResponse" | "ParseError";
    error?: unknown;
  }>
> {}

const make = (config: ClientConfig) =>
  Effect.gen(function* () {
    const client = createClient<paths>({ baseUrl: config.baseUrl });

    const request = <A>(
      schema: Schema.Schema<A>,
      request: (ref: typeof client) => Promise<{ data?: A; error?: unknown }>
    ): Effect.Effect<A, ClientError> =>
      Effect.async<A, ClientError>((cb) => {
        request(client).then(({ data, error }) => {
          if (error !== undefined) {
            return cb(
              Effect.fail(new ClientError({ error, reason: "RequestError" }))
            );
          } else if (data === undefined) {
            return cb(
              Effect.fail(new ClientError({ reason: "EmptyResponse" }))
            );
          }

          return cb(Effect.succeed(data));
        });
      }).pipe(
        Effect.flatMap(Schema.decodeEither(schema)),
        Effect.catchTag("ParseError", (error) =>
          Effect.fail(new ClientError({ error, reason: "ParseError" }))
        )
      );

    return { client, request };
  });

export class Client extends Context.Tag("Client")<
  Client,
  Effect.Effect.Success<ReturnType<typeof make>>
>() {
  static readonly layer = (config: Config.Config.Wrap<ClientConfig>) =>
    Layer.effect(this, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(
      Layer.orDie
    );
}

Manage internal state

All you need is XState here.

It handles every situation and usecase, some examples:

  • Fetch initial state (instead of using useEffect πŸ’πŸΌβ€β™‚οΈ)
  • Make async requests (loading state, errors, success)
  • Avoid undesirable situations (like forgetting to call setLoading(false) πŸ˜‘)
  • Separate state logic from components code 🀝

I always combine XState to manage the state logic and Effect for actions and actors (i.e. requests) πŸ†

Result: components become state-free, you can focus on style and layout:

SignInForm.tsx
export default function SignInForm() {
  const [snapshot, send] = useMachine(Machine.machine, {
    input: {
      /// Provide initial values
    },
  });

  /// Focus on the UI and send events on user interactions πŸš€
  return (
    <form
      className="flex flex-col gap-y-24"
      onSubmit={(event) => send({ type: "submit-form", event })}
    >
      <div className="flex flex-col gap-y-12">
        <TextField
          id="email"
          name="email"
          type="email"
          inputMode="email"
          label="Email"
          placeholder="[email protected]"
          value={snapshot.context.email}
          onChange={(email) =>
            send({ type: "update-form", context: { email } })
          }
        />
        <TextField
          id="password"
          name="password"
          type="password"
          label="Password"
          placeholder="Enter password"
          value={snapshot.context.password}
          onChange={(password) =>
            send({ type: "update-form", context: { password } })
          }
        />
      </div>

      <Button
        type="submit"
        disabled={snapshot.matches("Submitting")}
      >
        Log in
      </Button>
    </form>
  );
}

Styling

TailwindCSS and never look back 🫑

I have been using tailwindcss 4.0 (alpha) for a while now and it (mostly) works great: no more javascript configuration, everything is CSS πŸš€

globals.css
@import url("https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;600;700&display=swap");
@import "tailwindcss/preflight" layer(base);
@import "tailwindcss/utilities" layer(utilities);

/* Define the theme all in CSS 🎨 */
@theme {
  --font-family-work-sans: Work Sans, sans-serif;

  --color-black: #000;
  --color-white: #fff;

  --font-size-12: 0.75rem;
  --spacing-2: 0.125rem;
  --width-20: 1.25rem;
  
  /* And more πŸš€ */
}

I then sprinkle some clsx, tailwind-merge, and class-variance-authority on top of that πŸ‘¨β€πŸ³

Components

Long gone are the days in which you implement all your components from scratch every time πŸ’πŸΌβ€β™‚οΈ

In this day and age we have projects like shadcn/ui πŸͺ„

shadcn/ui is built on top of radix-ui. It gives you fully customizable and accessible components πŸͺ„

I often use also React Aria, depending of the features I need and how they integrate in my apps

You can then mix-and-match all the components to build more advanced UIs, examples:

  • DatePicker = Calendar + Popover
  • MultiSelect = ListBox + Popover
DatePicker.tsx
function DatePicker(props: DatePickerProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button >
          {/* Trigger button πŸ‘‰ */}
        </Button>
      </PopoverTrigger>
      <PopoverContent>
        {/* Calendar content of the Popover πŸ‘‡ */}
        <Calendar />
      </PopoverContent>
    </Popover>
  );
}

This is it, this is (probably) all that you need πŸ’πŸΌβ€β™‚οΈ

package.json
"dependencies": {
  "effect": "^3.3.0",
  "@effect/platform": "^0.56.0",
  "@effect/schema": "^0.67.21",
  "openapi-fetch": "^0.9.7",

  "@xstate/react": "^4.1.0",
  "xstate": "^5.11.0",

  "tailwindcss": "4.0.0-alpha.15",
  "@tailwindcss/postcss": "4.0.0-alpha.14",
  "tailwind-merge": "^2.2.2",
  "class-variance-authority": "^0.7.0",
  "clsx": "^2.1.0",

  "@radix-ui/react-checkbox": "^1.0.4",
  "@radix-ui/react-popover": "^1.0.7",
  "@radix-ui/react-dialog": "^1.0.5",
  "react-aria-components": "^1.1.1",

  /// ...
}

All other dependencies are usually project-specific, things like MDX, Supabase, dates, etc.

The ecosystem is great, isn't it? πŸš€

See you next πŸ‘‹

Get next Wednesday’s issue

The next focused idea arrives by email. Free, and easy to leave at any time.

Free, no spam, unsubscribe anytime. See the privacy policy.