Controlling Comparisons in FobX
This document explains how to control when FobX considers observable values to have changed by using different comparison strategies.
Structural Comparison
FobX supports structural comparison of observable values. This is useful when you want to compare objects by their structure rather than by reference equality.
Configuring Structural Comparison
Before using structural comparison, you need to configure it globally:
import { configure } from "@fobx/core"
import { deepEqual } from "fast-equals"
// Configure FobX to use deepEqual for structural comparisons
configure({
comparer: { structural: deepEqual },
})
The structural
comparer can be any function that takes two arguments and returns a boolean indicating if they are equal. In the example above, we use deepEqual
from the fast-equals
library.
Making Observables Use Structural Comparison
Once configured, you can create observables that use structural comparison in several ways:
1. Using makeObservable
with "structural"
annotation
import { makeObservable } from "@fobx/core"
const user = makeObservable({
profile: { name: "Alice", age: 25 },
}, {
profile: ["observable", "structural"],
})
// Changes that are structurally equivalent won't trigger reactions
user.profile = { name: "Alice", age: 25 } // No reaction triggered
// Changes that are structurally different will trigger reactions
user.profile = { name: "Bob", age: 30 } // Reaction triggered
2. Using computed
with structural comparison
import { computed, observable, reaction, runInAction } from "@fobx/core"
const items = observable([1, 2, 3])
const total = computed(
() => {
// Return a new object each time
return { sum: items.reduce((acc, val) => acc + val, 0) }
},
{ comparer: "structural" },
)
// Note: Computed values in FobX are lazily evaluated and only run
// when they're being tracked by a reaction
reaction(
() => total.value,
(sum) => console.log("Sum changed:", sum),
)
// IMPORTANT: When making multiple changes that should be treated as one transaction,
// use runInAction to batch them together
runInAction(() => {
// Modifying the array but keeping the same sum won't trigger the reaction
items[0] = 0
items[1] = 3
items[2] = 3
})
// total.value is still { sum: 6 }, reaction doesn't fire
// Changing the sum will trigger the reaction
items.push(4)
// total.value is now { sum: 10 }, reaction fires
Custom Equality Functions
Besides structural comparison, FobX allows defining custom equality functions for fine-grained control over when to consider values equal.
Using Custom Equality Function with observable
import { observableBox } from "@fobx/core"
// Custom equality function that ignores case for strings
const caseInsensitiveObservable = observableBox("hello", {
equals: (oldValue, newValue) =>
typeof oldValue === "string" &&
typeof newValue === "string" &&
oldValue.toLowerCase() === newValue.toLowerCase(),
})
// These won't trigger reactions because the case-insensitive values are equal
caseInsensitiveObservable.value = "HELLO"
caseInsensitiveObservable.value = "Hello"
// This will trigger reactions because the value is different case-insensitively
caseInsensitiveObservable.value = "world"
Using Custom Equality Function with makeObservable
import { makeObservable } from "@fobx/core"
// Custom equality function for numeric values
const roundingEqualityFn = (a, b) => Math.floor(a) === Math.floor(b)
const stats = makeObservable({
score: 10.2,
}, {
score: ["observable", roundingEqualityFn],
})
// Won't trigger reactions because floor values are equal
stats.score = 10.8
// Will trigger reactions because floor values differ
stats.score = 11.2
Combining Shallow Observables with Comparison Functions
FobX allows you to combine observable.shallow
with custom equality functions for even more control over reactivity.
Understanding observable.shallow
Behavior
When using observable.shallow
annotation, observable collections are created but their items remain non-observable, and reference equality is used by default:
import { makeObservable, reaction } from "@fobx/core"
// An array of users that we want to remain shallow (not make user objects observable)
const users = makeObservable({
list: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
}, {
list: "observable.shallow",
})
// You can also use observable() the same way:
const usersAlt = observable({
list: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
}, {
list: "observable.shallow",
})
reaction(
() => users.list,
(userList) => console.log("User list changed:", userList),
)
// Operations on the collection trigger reactions
users.list.push({ id: 3, name: "Charlie" })
// Replacing the entire collection will also trigger a reaction
users.list = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]
Unlike regular deep observables which can use structural comparison, shallow observables rely on reference equality by default, so replacing a collection with a structurally identical one will still trigger reactions.
Using observable.shallow
with Custom Equality Function
You can override the default reference equality by providing a custom equality function:
import { makeObservable, reaction } from "@fobx/core"
// A settings object where we only care about specific properties
const settings = makeObservable({
config: {
theme: "dark",
fontSize: 16,
cache: { temporaryData: [1, 2, 3] }, // We don't care about comparing this
lastUpdated: Date.now(), // We don't care about comparing this
},
}, {
config: [
"observable.shallow",
(oldValue, newValue) => {
// Only compare the keys we care about
return oldValue.theme === newValue.theme &&
oldValue.fontSize === newValue.fontSize
},
],
})
reaction(
() => settings.config,
(config) => console.log("Important settings changed:", config),
)
// Won't trigger reaction (important keys unchanged)
settings.config = {
theme: "dark",
fontSize: 16,
cache: { temporaryData: [4, 5, 6] }, // Different but we don't care
lastUpdated: Date.now(), // Different but we don't care
}
// Will trigger reaction (theme property changed)
settings.config = {
theme: "light", // Changed!
fontSize: 16,
cache: { temporaryData: [4, 5, 6] },
lastUpdated: Date.now(),
}
This pattern is excellent for configuration objects where you want to ignore changes to volatile or non-important properties.
Best Practices for API Data
When working with API data, here are some approaches to consider:
import { makeObservable, reaction } from "@fobx/core"
class ProductStore {
constructor() {
makeObservable(this, {
// Use shallow observables for collections of data objects
// to avoid making each item observable
products: "observable.shallow",
selectedProductId: "observable",
selectedProduct: "computed",
})
}
products = [
{ id: 1, name: "Phone", price: 599.99 },
{ id: 2, name: "Laptop", price: 1299.99 },
]
selectedProductId = 1
get selectedProduct() {
return this.products.find((p) => p.id === this.selectedProductId) || null
}
// When refetching products, consider implementing your own comparison
// to avoid unnecessary reactions
updateProducts(newProducts) {
// Option 1: Simple reference check (default behavior)
this.products = newProducts
// Option 2: Custom implementation to avoid unnecessary updates
// if you need structural comparison
/*
if (!areProductListsEqual(this.products, newProducts)) {
this.products = newProducts;
}
*/
}
selectProduct(id) {
this.selectedProductId = id
}
}
// Helper function (not part of FobX)
function areProductListsEqual(list1, list2) {
if (list1.length !== list2.length) return false
return list1.every((item, i) =>
item.id === list2[i].id &&
item.name === list2[i].name &&
item.price === list2[i].price
)
}
const store = new ProductStore()
// Setup reactions
reaction(
() => store.products,
(products) => console.log("Products list changed, updating UI..."),
)
reaction(
() => store.selectedProduct,
(product) => console.log("Selected product changed:", product?.name),
)
With this approach:
- Products remain shallow observables so the individual product objects aren't made observable
- If you need structural comparison, you can implement it in your methods before updating the observable value
- The computed
selectedProduct
property efficiently derives from the observable state
Important Behavior Notes
- Computed Value Optimization: Computed values in FobX are optimized and only calculate when they're being tracked by a reaction. If a computed isn't being observed, it won't recalculate until it's accessed.
- Reaction Comparisons: When a reaction fires, it compares the new value with the initial value from when the reaction was created, not with the most recently seen value. This behavior is important to understand when designing custom equality functions.
- Transactions Matter: When making multiple changes that should be treated as a single update, always wrap them in
runInAction
. Without this, each individual change might trigger unnecessary computed recalculations, leading to unexpected behavior with comparison functions.
Practical Use Cases
Performance Optimization
Structural comparison involves trade-offs that should be carefully considered:
// Consider this scenario:
const userData = observable({
profile: {
personal: { name: "Alice", age: 30 },
preferences: { theme: "dark", notifications: true },
statistics: {/* possibly large nested data */},
},
})
- Prevents unnecessary reactions when objects are recreated but structurally identical
- Particularly valuable when reactions are expensive (DOM updates, re-renders, network calls)
- Works well with immutable data patterns where new objects are created frequently
- The structural comparison itself is more expensive than reference equality
- The larger and more nested the objects being compared, the more costly the comparison
- For very frequent updates to large objects, the comparison cost may outweigh the benefits
- When the cost of running the reaction (e.g., a component re-render) is higher than the cost of the comparison
- When working with immutable data patterns where objects are frequently recreated
- For objects of moderate size that don't change extremely frequently
- When comparing very large, deeply nested objects
- When the observable updates extremely frequently (many times per second)
- When the reaction is simple and inexpensive to run
Form Data Validation
Custom equality functions are useful for form validation where you might want to consider values equal if they're within a certain range or match a particular pattern:
const formData = makeObservable({
email: "",
phoneNumber: "",
searchQuery: "",
}, {
// Only consider email changed if the normalized version is different
email: [
"observable",
(oldValue, newValue) =>
oldValue.trim().toLowerCase() === newValue.trim().toLowerCase(),
],
// Only consider phone numbers different if the actual digits change
// (ignores formatting differences like (555) 123-4567 vs 5551234567)
phoneNumber: [
"observable",
(oldValue, newValue) =>
oldValue.replace(/\D/g, "") === newValue.replace(/\D/g, ""),
],
// Only trigger reactions for search queries with meaningful differences
// (ignores extra spaces, treats "t-shirt" the same as "tshirt", etc)
searchQuery: [
"observable",
(oldValue, newValue) => {
const normalize = (str) =>
str.trim().toLowerCase()
.replace(/\s+/g, " ") // normalize spaces
.replace(/[^a-z0-9 ]/g, "") // remove special chars
return normalize(oldValue) === normalize(newValue)
},
],
})
Complex Data Structures
For complex data structures like nested objects or arrays, structural comparison ensures that only genuine changes in data structure trigger reactions:
import { configure, observable, reaction } from "@fobx/core"
import { deepEqual } from "fast-equals"
configure({
comparer: { structural: deepEqual },
})
const nestedData = observable({
users: [
{ id: 1, details: { name: "Alice", preferences: { theme: "dark" } } },
{ id: 2, details: { name: "Bob", preferences: { theme: "light" } } },
],
})
// Reaction will only run if the actual structure changes when using structural comparison
reaction(
() => nestedData.users,
(users) => console.log("Users updated", users),
{ equals: "structural" },
)
Complete Example
// Example with both structural and custom equality functions
import { configure, makeObservable, observable, reaction } from "@fobx/core"
import { deepEqual } from "fast-equals"
// Configure structural comparison
configure({
comparer: { structural: deepEqual },
})
// Object with different comparison strategies
const dashboard = makeObservable({
// Will use structural comparison
userData: { name: "Alice", preferences: { theme: "dark" } },
// Will use custom comparison (value within the same 5-point range)
approximateScore: 75,
}, {
userData: ["observable", "structural"],
approximateScore: [
"observable",
(a, b) => Math.floor(a / 5) === Math.floor(b / 5),
],
})
// Track changes
reaction(
() => dashboard.userData,
(userData) => console.log("User data changed:", userData),
)
reaction(
() => dashboard.approximateScore,
(score) => console.log("Score changed significantly:", score),
)
// Won't trigger reaction (structurally equal)
dashboard.userData = { name: "Alice", preferences: { theme: "dark" } }
// Will trigger reaction (structurally different)
dashboard.userData = { name: "Alice", preferences: { theme: "light" } }
// Won't trigger reaction (same 5-point range: 75-79)
dashboard.approximateScore = 79
// Will trigger reaction (different 5-point range: 80-84)
dashboard.approximateScore = 80