diff --git a/.changeset/dedupe-with.md b/.changeset/dedupe-with.md
new file mode 100644
index 0000000..124476e
--- /dev/null
+++ b/.changeset/dedupe-with.md
@@ -0,0 +1,19 @@
+---
+"@effect-atom/atom": minor
+---
+
+Add `Atom.dedupeWith` combinator for customizing write equivalence.
+
+By default every atom deduplicates writes using `Equal.equals`, which falls back
+to reference equality (`===`) for plain objects and arrays. `dedupeWith` lets
+you attach a custom `Equivalence` so that structurally-equal writes become
+no-ops — useful for atoms holding API payloads, derived arrays, or any value
+type that does not implement `Equal`.
+
+```ts
+import { Atom } from "@effect-atom/atom"
+
+const user = Atom.make({ id: "u1", name: "Alice" }).pipe(
+ Atom.dedupeWith((a, b) => a.id === b.id && a.name === b.name)
+)
+```
diff --git a/packages/atom/src/Atom.ts b/packages/atom/src/Atom.ts
index 08726e2..2de4676 100644
--- a/packages/atom/src/Atom.ts
+++ b/packages/atom/src/Atom.ts
@@ -13,6 +13,8 @@ import * as EffectContext from "effect/Context"
import * as Duration from "effect/Duration"
import * as Effect from "effect/Effect"
import * as Either from "effect/Either"
+import * as Equal from "effect/Equal"
+import type * as Equivalence from "effect/Equivalence"
import * as Exit from "effect/Exit"
import * as Fiber from "effect/Fiber"
import * as FiberId from "effect/FiberId"
@@ -55,6 +57,7 @@ export interface Atom extends Pipeable, Inspectable.Inspectable {
readonly refresh?: (f: (atom: Atom) => void) => void
readonly label?: readonly [name: string, stack: string]
readonly idleTTL?: number
+ readonly eq: Equivalence.Equivalence
}
/**
@@ -172,8 +175,38 @@ export const setIdleTTL: {
const removeTtl = setIdleTTL(0)
+/**
+ * Override the equivalence used to deduplicate writes to this atom.
+ *
+ * By default every atom uses `Equal.equals`, which short-circuits writes when
+ * the new value is structurally equal to the previous one. For plain objects
+ * and arrays that do not implement `Equal`, `Equal.equals` falls back to
+ * reference equality (`===`), causing every new reference — even a
+ * structurally identical one — to notify subscribers and invalidate
+ * downstream atoms.
+ *
+ * `dedupeWith` lets you supply a custom `Equivalence` (e.g. a deep-equality
+ * check, an id-based check, or a domain-specific comparison) so that writes
+ * yielding an "equivalent" value become no-ops.
+ *
+ * @since 1.0.0
+ * @category combinators
+ */
+export const dedupeWith: {
+ (eq: Equivalence.Equivalence): >(self: Self) => Self
+ >(self: Self, eq: Equivalence.Equivalence>): Self
+} = dual<
+ (eq: Equivalence.Equivalence) => >(self: Self) => Self,
+ >(self: Self, eq: Equivalence.Equivalence>) => Self
+>(2, (self, eq) =>
+ Object.assign(Object.create(Object.getPrototypeOf(self)), {
+ ...self,
+ eq
+ }))
+
const AtomProto = {
[TypeId]: TypeId,
+ eq: Equal.equals,
pipe() {
return pipeArguments(this, arguments)
},
diff --git a/packages/atom/src/internal/registry.ts b/packages/atom/src/internal/registry.ts
index 1e71203..364ed71 100644
--- a/packages/atom/src/internal/registry.ts
+++ b/packages/atom/src/internal/registry.ts
@@ -1,5 +1,4 @@
import * as Effect from "effect/Effect"
-import * as Equal from "effect/Equal"
import * as Exit from "effect/Exit"
import { constVoid, pipe } from "effect/Function"
import { globalValue } from "effect/GlobalValue"
@@ -341,7 +340,7 @@ class Node {
}
this.state = NodeState.valid
- if (Equal.equals(this._value, value)) {
+ if (this.atom.eq(this._value, value)) {
return
}
diff --git a/packages/atom/test/Atom.test.ts b/packages/atom/test/Atom.test.ts
index 50d0748..506d753 100644
--- a/packages/atom/test/Atom.test.ts
+++ b/packages/atom/test/Atom.test.ts
@@ -1628,6 +1628,99 @@ describe("Atom", () => {
expect(storage.get("test-key")).toEqual(JSON.stringify(42))
})
})
+
+ describe("dedupeWith", () => {
+ it("plain-object atom without dedupeWith re-notifies on structurally-equal new reference", () => {
+ const atom = Atom.make({ n: 1 })
+ const r = Registry.make()
+ r.get(atom)
+ let count = 0
+ const cancel = r.subscribe(atom, () => {
+ count++
+ })
+ r.set(atom, { n: 1 })
+ r.set(atom, { n: 1 })
+ expect(count).toEqual(2)
+ cancel()
+ })
+
+ it("dedupeWith with deep equality suppresses notify on structurally-equal writes", () => {
+ const eq = (a: { n: number }, b: { n: number }) => a.n === b.n
+ const atom = Atom.make({ n: 1 }).pipe(Atom.dedupeWith(eq))
+ const r = Registry.make()
+ r.get(atom)
+ let count = 0
+ const cancel = r.subscribe(atom, () => {
+ count++
+ })
+ r.set(atom, { n: 1 })
+ r.set(atom, { n: 1 })
+ expect(count).toEqual(0)
+ r.set(atom, { n: 2 })
+ expect(count).toEqual(1)
+ cancel()
+ })
+
+ it("dedupeWith does not suppress the initial value", () => {
+ const atom = Atom.make({ n: 1 }).pipe(
+ Atom.dedupeWith((a: { n: number }, b: { n: number }) => a.n === b.n)
+ )
+ const r = Registry.make()
+ expect(r.get(atom)).toEqual({ n: 1 })
+ })
+
+ it("dedupeWith prevents downstream invalidation of derived atoms", () => {
+ const source = Atom.make({ n: 1 }).pipe(
+ Atom.keepAlive,
+ Atom.dedupeWith((a: { n: number }, b: { n: number }) => a.n === b.n)
+ )
+ let derivations = 0
+ const derived = Atom.readable((get) => {
+ derivations++
+ return get(source).n * 2
+ }).pipe(Atom.keepAlive)
+ const r = Registry.make()
+ expect(r.get(derived)).toEqual(2)
+ expect(derivations).toEqual(1)
+ r.set(source, { n: 1 })
+ expect(r.get(derived)).toEqual(2)
+ expect(derivations).toEqual(1)
+ r.set(source, { n: 3 })
+ expect(r.get(derived)).toEqual(6)
+ expect(derivations).toEqual(2)
+ })
+
+ it("composes with setIdleTTL in either order", () => {
+ const eq = (a: { n: number }, b: { n: number }) => a.n === b.n
+ const a = Atom.make({ n: 1 }).pipe(
+ Atom.dedupeWith(eq),
+ Atom.setIdleTTL("1 second")
+ )
+ const b = Atom.make({ n: 1 }).pipe(
+ Atom.setIdleTTL("1 second"),
+ Atom.dedupeWith(eq)
+ )
+ expect(a.eq).toBe(eq)
+ expect(b.eq).toBe(eq)
+ expect(a.idleTTL).toEqual(1000)
+ expect(b.idleTTL).toEqual(1000)
+ })
+
+ it("default eq is Equal.equals — primitive writes still deduplicate unchanged", () => {
+ const atom = Atom.make(1)
+ const r = Registry.make()
+ r.get(atom)
+ let count = 0
+ const cancel = r.subscribe(atom, () => {
+ count++
+ })
+ r.set(atom, 1)
+ expect(count).toEqual(0)
+ r.set(atom, 2)
+ expect(count).toEqual(1)
+ cancel()
+ })
+ })
})
interface BuildCounter {