@echristian/effect-atom-solid
v0.0.3
Published
Solid bindings for @effect-atom/atom
Maintainers
Readme
@echristian/effect-atom-solid
A reactive state management library for Effect, with Solid.js integration.
Installation
pnpm add @echristian/effect-atom-solidCreating a Counter with Atom
Let's create a simple Counter component, which will increment a number when you click a button.
We will use Atom.make to create our Atom, which is a reactive state container.
We can then use the useAtomValue & useAtomSet hooks to read and update the value
of the Atom.
import { Atom, useAtomValue, useAtomSet } from "@echristian/effect-atom-solid"
const countAtom = Atom.make(0).pipe(
// By default, the Atom will be reset when no longer used.
// This is useful for cleaning up resources when the component unmounts.
//
// If you want to keep the value, you can use `Atom.keepAlive`.
//
Atom.keepAlive,
)
function App() {
return (
<div>
<Counter />
<br />
<CounterButton />
</div>
)
}
function Counter() {
const count = useAtomValue(countAtom)
// In Solid.js, hooks return Accessors - call them as functions to get the value
return <h1>{count()}</h1>
}
function CounterButton() {
const setCount = useAtomSet(countAtom)
return (
<button onClick={() => setCount((count) => count + 1)}>Increment</button>
)
}Derived State
You can create derived state from an Atom in a couple of ways.
import { Atom } from "@echristian/effect-atom-solid"
const countAtom = Atom.make(0)
// You can use the `get` function to get the value of another Atom.
//
// The type of `get` is `Atom.Context`, which also has a bunch of other methods
// on it to manage Atoms.
//
const doubleCountAtom = Atom.make((get) => get(countAtom) * 2)
// You can also use the `Atom.map` function to create a derived Atom.
const tripleCountAtom = Atom.map(countAtom, (count) => count * 3)Working with Effects
You can also pass effects to the Atom.make function.
When working with effectful Atoms, you will get back a Result type.
You can see all the ways to work with Result here: https://tim-smart.github.io/effect-atom/atom/Result.ts.html
import { Atom, Result } from "@echristian/effect-atom-solid"
import { Effect } from "effect"
// ┌─── Atom.Atom<Result.Result<number>>
// ▼
const countAtom = Atom.make(Effect.succeed(0))
// You can also pass a function to get access to the `Atom.Context`
//
// `get.result` can be used in `Effect`s to get the value of an `Atom.Atom<Result.Result>`.
//
// ┌─── Atom.Atom<Result.Result<number>>
// ▼
const resultWithContextAtom = Atom.make(
Effect.fnUntraced(function* (get: Atom.Context) {
const count = yield* get.result(countAtom)
return count + 1
}),
)Working with scoped Effects
All Atoms that use effects are provided with a Scope, so you can add finalizers
that will be run when the Atom is no longer used.
import { Atom } from "@echristian/effect-atom-solid"
import { Effect } from "effect"
const resultAtom = Atom.make(
Effect.gen(function* () {
// Add a finalizer to the `Scope` for this Atom
// It will run when the Atom is rebuilt or no longer needed
yield* Effect.addFinalizer(() => Effect.log("finalizer"))
return "hello"
}),
)Working with Effect Services / Layers
import { Atom } from "@echristian/effect-atom-solid"
import { Effect } from "effect"
class Users extends Effect.Service<Users>()("app/Users", {
effect: Effect.gen(function* () {
const getAll = Effect.succeed([
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
{ id: "3", name: "Charlie" },
])
return { getAll } as const
}),
}) {}
// Create a `AtomRuntime` from a `Layer`.
//
// ┌─── Atom.AtomRuntime<Users>
// ▼
const runtimeAtom = Atom.runtime(Users.Default)
// You can then use the `AtomRuntime` to make Atoms that use the services from the `Layer`.
const usersAtom = runtimeAtom.atom(
Effect.gen(function* () {
const users = yield* Users
return yield* users.getAll
}),
)Adding global Layers to AtomRuntimes
This is useful for setting up Tracers, Loggers, ConfigProviders, etc.
import { Atom } from "@echristian/effect-atom-solid"
import { ConfigProvider, Layer } from "effect"
Atom.runtime.addGlobalLayer(
Layer.setConfigProvider(ConfigProvider.fromJson(import.meta.env)),
)Working with Streams
import { Atom, Result, useAtom } from "@echristian/effect-atom-solid"
import { Cause, Schedule, Stream } from "effect"
// This will be a simple Atom that emits a incrementing number every second.
//
// Atom.make will give back the latest value of a `Stream` as a `Result`.
//
// ┌─── Atom.Atom<Result.Result<number>>
// ▼
const countAtom = Atom.make(Stream.fromSchedule(Schedule.spaced(1000)))
// You can use `Atom.pull` to create a specialized Atom that will pull from a `Stream`
// one chunk at a time.
//
// This is useful for infinite scrolling or paginated data.
//
// With a `AtomRuntime`, you can use `runtimeAtom.pull` to create a pull Atom.
//
// ┌─── Atom.Writable<Atom.PullResult<number>, void>
// ▼
const countPullAtom = Atom.pull(Stream.make(1, 2, 3, 4, 5))
// Here is a component that uses `countPullAtom` to display the numbers in a list.
//
// You can use `useAtom` to both read the value of an Atom and gain access to the
// setter function.
//
// Each time the setter function is called, it will pull a new chunk of data
// from the `Stream`, and append it to the list.
function CountPullAtomComponent() {
const [result, pull] = useAtom(countPullAtom)
// In Solid.js, call the accessor as a function to get the value
return Result.builder(result())
.onInitial(() => <div>Loading...</div>)
.onFailure((cause) => <div>Error: {Cause.pretty(cause)}</div>)
.onSuccess(({ items }, { waiting }) => (
<div>
<ul>
{items.map((item) => (
<li>{item}</li>
))}
</ul>
<button onClick={() => pull()}>Load more</button>
{waiting ? <p>Loading more...</p> : <p>Loaded chunk</p>}
</div>
))
.render()
}Working with sets of Atoms
import { Atom } from "@echristian/effect-atom-solid"
import { Effect } from "effect"
class Users extends Effect.Service<Users>()("app/Users", {
effect: Effect.gen(function* () {
const findById = (id: string) => Effect.succeed({ id, name: "John Doe" })
return { findById } as const
}),
}) {}
// Create a `AtomRuntime` from a `Layer`
const runtimeAtom = Atom.runtime(Users.Default)
// Atoms work by reference, so we need to use `Atom.family` to dynamically create a
// set of Atoms from a key.
//
// `Atom.family` will ensure that we get a stable reference to the Atom for each key.
//
// ┌─── (arg: string) => Atom.Atom<Result<{ id: string; name: string; }>>
// ▼
const userAtom = Atom.family((id: string) =>
runtimeAtom.atom(
Effect.gen(function* () {
const users = yield* Users
return yield* users.findById(id)
}),
),
)Working with functions
import { Atom, useAtomSet } from "@echristian/effect-atom-solid"
import { Effect, Exit } from "effect"
// Create a simple `Atom.fn` that logs a number
const logAtom = Atom.fn(
Effect.fnUntraced(function* (arg: number) {
yield* Effect.log("got arg", arg)
}),
)
function LogComponent() {
// To call the `Atom.fn`, we need to use the `useAtomSet` hook
const logNumber = useAtomSet(logAtom)
return <button onClick={() => logNumber(42)}>Log 42</button>
}
// You can also use it with `Atom.runtime`
class Users extends Effect.Service<Users>()("app/Users", {
effect: Effect.gen(function* () {
const create = (name: string) => Effect.succeed({ id: 1, name })
return { create } as const
}),
}) {}
const runtimeAtom = Atom.runtime(Users.Default)
// Here we are using `runtimeAtom.fn` to create a function from the `Users.create`
// method.
const createUserAtom = runtimeAtom.fn(
Effect.fnUntraced(function* (name: string) {
const users = yield* Users
return yield* users.create(name)
}),
)
function CreateUserComponent() {
// If your function returns a `Result`, you can use the useAtomSet hook with `mode: "promiseExit"`
const createUser = useAtomSet(createUserAtom, { mode: "promiseExit" })
return (
<button
onClick={async () => {
const exit = await createUser("John")
if (Exit.isSuccess(exit)) {
console.log(exit.value)
}
}}
>
Create user
</button>
)
}Wrapping an event listener
import { Atom } from "@echristian/effect-atom-solid"
// This is a simple Atom that will emit the current scroll position of the
// window.
const scrollYAtom: Atom.Atom<number> = Atom.make((get) => {
// The handler will use `get.setSelf` to update the value of itself
const onScroll = () => {
get.setSelf(window.scrollY)
}
// We need to use `get.addFinalizer` to remove the event listener when the
// Atom is no longer used.
window.addEventListener("scroll", onScroll)
get.addFinalizer(() => window.removeEventListener("scroll", onScroll))
// Return the current scroll position
return window.scrollY
})Integration with search params
import { Atom } from "@echristian/effect-atom-solid"
import { Option, Schema } from "effect"
// Create an Atom that reads and writes to the URL search parameters.
//
// ┌─── Atom.Writable<string>
// ▼
const simpleParamAtom = Atom.searchParam("paramName")
// You can also use a schema to further parse the value
//
// ┌─── Atom.Writable<Option<number>>
// ▼
const numberParamAtom = Atom.searchParam("paramName", {
schema: Schema.NumberFromString,
})Integration with local storage
import { Atom } from "@echristian/effect-atom-solid"
import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Schema } from "effect"
const runtime = Atom.runtime(BrowserKeyValueStore.layerLocalStorage)
// Create an Atom that reads and writes to `localStorage`.
//
// It uses `Schema` to define the type of the value stored.
//
// ┌─── Atom.Writable<boolean, boolean>
// ▼
const flagAtom = Atom.kvs({
runtime: runtime,
key: "flag",
schema: Schema.Boolean,
defaultValue: () => false,
})Integration with Reactivity from @effect/experimental
Reactivity is an Effect service that allows you make queries reactive when
mutations happen.
You can use an Atom.runtime to hook into the Reactivity service and trigger
Atom refreshes when mutations happen.
import { Atom } from "@echristian/effect-atom-solid"
import { Effect, Layer } from "effect"
import { Reactivity } from "@effect/experimental"
const runtimeAtom = Atom.runtime(Layer.empty)
let i = 0
// ┌─── Atom.Atom<number>
// ▼
const count = Atom.make(() => i++).pipe(
// Refresh when the "counter" key changes
Atom.withReactivity(["counter"]),
// Or refresh when "counter" or "counter:1" or "counter:2" changes
Atom.withReactivity({
counter: [1, 2],
}),
)
const someMutation = runtimeAtom.fn(
Effect.fn(function* () {
yield* Effect.log("Mutating the counter")
}),
// Invalidate the "counter" key when the Effect is finished
{ reactivityKeys: ["counter"] },
)
const someMutationManual = runtimeAtom.fn(
Effect.fn(function* () {
yield* Effect.log("Mutating the counter again")
// You can also manually invalidate the "counter" key
yield* Reactivity.invalidate(["counter"])
}),
)@effect/rpc integration
You can use the AtomRpc module to create an RPC client with integration with
effect-atom. It offers apis for both queries and mutations.
import {
AtomRpc,
Result,
useAtomSet,
useAtomValue
} from "@echristian/effect-atom-solid"
import { Effect, Layer, Schema } from "effect"
import { BrowserSocket } from "@effect/platform-browser"
import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "@effect/rpc"
// Define the RPCs
class Rpcs extends RpcGroup.make(
Rpc.make("increment"),
Rpc.make("count", {
success: Schema.Number
})
) {}
// Use `AtomRpc.Tag` to create a special `Context.Tag` that builds the RPC client
class CountClient extends AtomRpc.Tag<CountClient>()("CountClient", {
group: Rpcs,
// Provide a `Layer` that provides the RpcClient.Protocol
protocol: RpcClient.layerProtocolSocket({
retryTransientErrors: true
}).pipe(
Layer.provide(BrowserSocket.layerWebSocket("ws://localhost:3000/rpc")),
Layer.provide(RpcSerialization.layerJson)
)
}) {}
function SomeComponent() {
// Use `CountClient.query` for readonly queries
const count = useAtomValue(CountClient.query("count", void 0, {
// You can also register reactivity keys, which can be used to invalidate
// the query
reactivityKeys: ["count"]
}))
// Use `CountClient.mutation` for mutations
const increment = useAtomSet(CountClient.mutation("increment"))
return (
<div>
{/* In Solid.js, call the accessor as a function */}
<p>Count: {Result.getOrElse(count(), () => 0)}</p>
<button
onClick={() =>
increment({
payload: void 0,
// Mutations can also have reactivity keys, which will invalidate
// the query when the mutation is done.
reactivityKeys: ["count"]
})}
>
Increment
</button>
</div>
)
}
// Or you can define custom atoms using the `CountClient.runtime`
const incrementAtom = CountClient.runtime.fn(Effect.fnUntraced(function*() {
const client = yield* CountClient // Use the Tag to access the client
yield* client("increment", void 0)
}))
// Or use it in your Effect services
class MyService extends Effect.Service<MyService>()("MyService", {
dependencies: [CountClient.layer], // Add the `CountClient` as a dependency
scoped: Effect.gen(function*() {
const client = yield* CountClient // Use the Tag to access the client
const useClient = () => client("increment", void 0)
return { useClient } as const
})
}) {}HttpApi integration
You can use the AtomHttpApi module to create an HTTP API client with
integration with effect-atom. It offers apis for both queries and mutations.
import {
AtomHttpApi,
Result,
useAtomSet,
useAtomValue
} from "@echristian/effect-atom-solid"
import {
FetchHttpClient,
HttpApi,
HttpApiEndpoint,
HttpApiGroup
} from "@effect/platform"
import { Effect, Schema } from "effect"
// Define your api
class Api extends HttpApi.make("api").add(
HttpApiGroup.make("counter").add(
HttpApiEndpoint.get("count", "/count").addSuccess(Schema.Number)
).add(
HttpApiEndpoint.post("increment", "/increment")
)
) {}
// Use `AtomHttpApi.Tag` to create a special `Context.Tag` that builds the client
class CountClient extends AtomHttpApi.Tag<CountClient>()("CountClient", {
api: Api,
// Provide a Layer that provides the HttpClient
httpClient: FetchHttpClient.layer,
baseUrl: "http://localhost:3000"
}) {}
function SomeComponent() {
// Use `CountClient.query` for readonly queries
const count = useAtomValue(CountClient.query("counter", "count", {
// You can register reactivity keys, which can be used to invalidate
// the query
reactivityKeys: ["count"]
}))
// Use `CountClient.mutation` for mutations
const increment = useAtomSet(CountClient.mutation("counter", "increment"))
return (
<div>
{/* In Solid.js, call the accessor as a function */}
<p>Count: {Result.getOrElse(count(), () => 0)}</p>
<button
onClick={() =>
increment({
payload: void 0,
// Mutations can also have reactivity keys, which will invalidate
// the query when the mutation is done.
reactivityKeys: ["count"]
})}
>
Increment
</button>
</div>
)
}
// Or you can define custom atoms using the `CountClient.runtime`
const incrementAtom = CountClient.runtime.fn(Effect.fnUntraced(function*() {
const client = yield* CountClient // Use the Tag to access the client
yield* client.counter.increment()
}))
// Or use it in your Effect services
class MyService extends Effect.Service<MyService>()("MyService", {
dependencies: [CountClient.layer], // Add the `CountClient` as a dependency
scoped: Effect.gen(function*() {
const client = yield* CountClient // Use the Tag to access the client
const useClient = () => client.counter.increment()
return { useClient } as const
})
}) {}Using RegistryProvider
You can use RegistryProvider to provide a custom registry to your components,
or to initialize atoms with specific values.
import { RegistryProvider, Atom, useAtomValue } from "@echristian/effect-atom-solid"
const countAtom = Atom.make(0)
function App() {
return (
// Optionally provide initial values for atoms
<RegistryProvider initialValues={[[countAtom, 10]]}>
<Counter />
</RegistryProvider>
)
}
function Counter() {
const count = useAtomValue(countAtom)
return <h1>{count()}</h1>
}