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:
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:
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 π
@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
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 ππΌββοΈ
"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 π