β€’

newsletter

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

Software development

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>
  );
}

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

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 πŸ‘‹

Start here.

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.