The Jetpack Compose docs hand you two factory functions and a one-line summary of each. That is not enough to choose between them in a real app. Below is the actual trade, stripped of folklore.
Quick take, both return a ProvidableCompositionLocal, are read with CompositionLocal.current, and are injected through CompositionLocalProvider. The docs frame staticCompositionLocalOf as the option for values that rarely or never change, and compositionLocalOf for values that may change, including animated ones. The common "use static for infrequent, dynamic for frequent" rule is a tidy summary, but it hides the real cost: who pays when a write happens.
What the docs actually say
With compositionLocalOf, the system tracks each read, so a value change only invalidates the composables that consumed that local. The price is a small tracking overhead on every read.
With staticCompositionLocalOf, no read is tracked. Reads are essentially free, but the moment the provider's value changes, every composable inside that provider's content block is invalidated. A single write ripples through the whole subtree.
That asymmetry is the entire decision.
Why the popular rule misleads developers
Most advice condenses the docs into "static for values that change rarely, dynamic for values that change often." It is incomplete. Frequency alone is the wrong axis. The right axis is blast radius of a write.
For a staticCompositionLocalOf value, reads are cheap and writes are expensive. For a compositionLocalOf value, reads are slightly more expensive and writes are surgical. A value that changes once a minute inside a provider whose subtree contains the whole app screen is a different proposition from a value that changes once a minute inside a single leaf composable.
Our analysis
There is a quiet second problem hiding inside staticCompositionLocalOf. The API cannot actually stop a developer from passing a changing value through the provider. It is a performance hint, not an immutability lock. So the "use it only for values that never change" rule is a discipline rule, not a guarantee.
For most teams, the safe default is compositionLocalOf. You pay a small tracking cost on every read, and you get predictable invalidation. Reach for staticCompositionLocalOf only when you can stand behind three conditions: the value really is stable for the lifetime of the provider, the subtree is small enough that a full invalidation is acceptable, and the read happens often enough that the tracking overhead actually matters.
In practice, the docs' own examples fit cleanly. Application context, font loaders, and similar shared objects are textbook staticCompositionLocalOf candidates. Theme colors, even when driven by a state holder, belong in compositionLocalOf because they can animate or switch.
A practical filter: the decision checklist
Before reaching for staticCompositionLocalOf, confirm all three:
- Stable value?** The provider's value will not change during its scope.
- Bounded subtree?** The provider wraps a limited tree, not the entire screen.
- Read-heavy?** The local is read in dozens of composables where tracking overhead accumulates.
If any answer is no or uncertain, default to compositionLocalOf.
Say you are building a design system
Imagine wiring a theme into a Compose app. You expose a Typography object through CompositionLocalProvider at the root, and several screens consume it. Typography itself is built once from design tokens and never mutates, so staticCompositionLocalOf is defensible and screens read it for free on every recomposition.
Now add a high-contrast mode toggle backed by a State object. That state changes when the user flips a switch. Wrap the high-contrast colors in a separate compositionLocalOf, and the toggle only recomposes composables that actually read those colors, instead of invalidating the whole screen.
Splitting "stable" from "changeable" locals is the simplest way to avoid the most common staticCompositionLocalOf footgun: treating it as a general performance switch when it is really a surgical tool.
Bottom line
Use staticCompositionLocalOf as a sharp tool for genuinely stable values inside well-bounded subtrees, and compositionLocalOf as the safe default. If you cannot articulate the blast radius of a write, you do not have a static case yet.
Key takeaways
- The real trade is read cost versus write blast radius, not "frequency of change."
- StaticCompositionLocalOf cannot enforce immutability; it only skips read tracking.
- Default to compositionLocalOf; opt into staticCompositionLocalOf only for known-stable values.
- Split theme and design tokens into separate providers by mutability profile.
- Treat the popular "static for infrequent" rule as a starting prompt, not a policy.
Frequently asked questions
What is the simplest rule for choosing between staticCompositionLocalOf and compositionLocalOf?
Pick staticCompositionLocalOf when the value will not change inside the provider and the subtree can absorb a full invalidation. Pick compositionLocalOf for anything that may change, including theme colors and animated values.
Does staticCompositionLocalOf make reads faster?
Yes. Skipping the tracker makes reads cheaper, but any write to the provider invalidates the entire subtree under it, which can be much more expensive than a targeted recomposition.
Can I stop other developers from writing a changing value into a staticCompositionLocalOf provider?
No. The API is a performance hint, not a guard. It is up to your team's code review and conventions to keep that provider's value stable.
Are Android context and font loaders good candidates for staticCompositionLocalOf?
Yes. The official documentation calls them out as examples of values that are not expected to change inside a CompositionLocalProvider's content.
How does this connect to Material 3 theming?
Material 3 uses CompositionLocal-based theming under the hood. Stable tokens like Typography are reasonable static candidates, while color schemes that respond to user settings belong in a compositionLocalOf so only color-reading composables recompose.