Blog Infos
Author
Published
Topics
, , ,
Published

This article assumes you’re already familiar with CompositionLocal. If not, start with the official docs.

Reading the Docs

Both staticCompositionLocalOf and compositionLocalOf return a ProvidableCompositionLocal<T> and are used with CompositionLocalProvider to supply and consume values.

The docs describe them like this:

compositionLocalOf

Changing the value provided during recomposition will invalidate the content of CompositionLocalProvider that read the value using CompositionLocal.current

staticCompositionLocalOf

Unlike compositionLocalOf, reads of a staticCompositionLocalOf are not tracked by the composer and changing the value provided in the CompositionLocalProvider call will cause the entirety of the content to be recomposed instead of just the places where in the composition the local value is used. This lack of tracking, however, makes a staticCompositionLocalOf more efficient when the value provided is highly unlikely to or will never change. For example, the android context, font loaders, or similar shared values, are unlikely to change for the components in the content of a the CompositionLocalProvider and should consider using a staticCompositionLocalOf. A color, or other theme like value, might change or even be animated therefore a compositionLocalOf should be used.

All clear, right?

If you are scratching your head, don’t worry. You are not alone. I of course asked ChatGPT, Gemini and Grok.

And then I opened Google. That can only mean one thing: I was confused.

What the internet says

When I searched around, I found different things:

One article mentions…

The official document states that you should only use staticCompositionLocalOf for a value that doesn’t change. But the issue is, how do you prevent the user or any developer from changing it? You can’t.

Therefore, it seems to be we should just use compositionLocalOf and NOT use staticCompositionLocalOf as a best practice.

How much benefits exactly? I agree to use staticCompositionLocalOf only if it is a constant value and can’t be changed. Then, this prevents the users from misusing it.

Another one says…

Use staticCompositionLocalOf when the data never changes during app runtime

…but the most common advice I’ve found is related to the frequency of changes:

Use staticCompositionLocalOf for rarely changing values, and compositionLocalOf for frequently changing ones.

This last one is the most common. It’s easy to remember and it echoes the docs: static for unlikely to change, dynamic for changing values.

Neat rule.

But misleading.

The Actual Trade-Off

Let’s simplify the cost model:

staticCompositionLocalOf
  • Read cost: very cheap (no tracking)
  • Write cost: expensive — invalidates the entire subtree under the provider.
compositionLocalOf
  • Read cost: More expensive (every read is tracked)
  • Write cost: Cheaper — only recomposes the nodes that actually read it.

Key insight:

With staticCompositionLocalOf, a single write at the root invalidates the entire app tree. With compositionLocalOf, only the readers recompose.

The Matrix Table

To reason about this, let’s think in terms of reads and writes.

Read = CompositionLocal value is read in a Composable

Write = CompositionLocal value is changed

If there aren’t any reads we don’t need a CompositionLocal, that’s why is not in the table.

No Writes

If the value never changes, there’s no risk of triggering expensive recompositions. In this case, compositionLocalOf only adds unnecessary overhead:

  • Few Reads: Even with just a handful of readers, compositionLocalOf still pays a small, ongoing tracking cost.
  • Several Reads: With many readers, that tracking cost balloons into a significant, constant overhead — for no benefit, since the value never updates.

staticCompositionLocalOf avoids all of this. For true constants, it’s the optimal choice: zero tracking cost and no recomposition risk.

No Writes means no need to track changes.

Few Reads + Few Writes

With only a few readers, the tracking cost of compositionLocalOf is negligible. And since writes do happen (even if infrequently), you want those updates to be as targeted as possible.

compositionLocalOf ensures that only the small set of readers recompose when the value changes. By contrast, staticCompositionLocalOf provides almost no advantage here: the tracking overhead you’d save is already minimal, while every write risks a costly full-tree recomposition.

In this scenario, targeted updates are the safer trade-off — compositionLocalOf wins.

Few reads means minimal tracking. On the other side we save a lot of recompositions.

Few Reads + Several Writes

With only a few readers, the tracking overhead of compositionLocalOf remains negligible. But now the value changes frequently, which makes the full-tree recompositions of staticCompositionLocalOf far too costly — you’d be forcing large parts of the UI to rebuild over and over, leading to visible jank.

compositionLocalOf avoids this by limiting recompositions to just the handful of components that actually consume the value. In this case, the small cost of tracking is easily outweighed by the savings from targeted updates.

When writes are frequent and reads are few, compositionLocalOf is the clear choice.

compositionLocalOf gets max gains when few reads meet several writes

Several Reads + Few Writes

When many parts of the UI read a value, compositionLocalOf incurs a constant, high overhead from tracking all of those reads. In contrast, writes are rare in this scenario — the value changes only occasionally.

That means the occasional full-tree recomposition cost of staticCompositionLocalOf is far cheaper overall than paying the continuous tracking penalty of compositionLocalOf.

With many readers and few writes, staticCompositionLocalOf is the more efficient choice.

compositionLocalOf tracking overhead outweights the low number of full-tree recompositions

Several Reads + Several Writes

The decision is a battle between two high costs:

  1. Dynamic Cost: High, persistent cost of tracking every read plus the high, repeated cost of near-full-tree recompositions.
  2. Static Cost: Zero cost for tracking plus the high, repeated cost of full-tree recompositions.

When the number of readers is very high (e.g., 99%), “targeted” effectively becomes “broadcast,” nullifying the main benefit of compositionLocalOf (skipping sibling/parent logic).

  • Dynamic (Cost of Tracking​ × Number Reads​) + (Cost Recompose​ × Number of Writes​)
  • Static (0) + (Cost of Recompose ​× Number of Writes​)

Since the recomposition cost is high and paid repeatedly in both cases, the optimal move is to eliminate the perpetual tracking cost (Cost of tracking​ × Number of Reads​).

Several Writes and Several Reads signals a design problem, staticCompositioinLocalOf might be the less bad option

Do All Changes Cost the Same?

Reminder: The Three Phases of Pain (and Performance)

Compose renders your UI in three sequential phases:

  1. Composition: What UI to show. (Rerunning the @Composable functions.)
  2. Layout: Where to place the UI. (Measuring and positioning elements.)
  3. Drawing: How the UI looks. (Painting pixels like colors or shadows.)

The type of change matters — it influences how expensive each write is.

The Low-Cost Change: Drawing-Only Updates

If your CompositionLocal only holds values for things like color, alpha, or corner radius, a change is relatively cheap.

  • When the value updates, the affected composables run (Composition), but the Layout system quickly sees that the size and position haven’t changed.
  • The expensive Layout phase is skipped entirely.
  • Only the Drawing phase runs to repaint the pixels.

In this low-cost scenario, the risk of jank is minimal. The performance difference between targeted vs broad recomposition is minimal, as they both skip the biggest bottleneck (Layout).

The High-Cost Change: Layout-Dependent Updates

If your CompositionLocal holds values that influence the size or structure, such as font size, padding, or spacing, the cost of the update skyrockets.

  • When the value updates, the Layout system recognizes that a dimension has changed.
  • The Layout phase MUST run. The affected component must be re-measured, and its parent(s) must also be checked and potentially re-measured.

This is where the targeted invalidation of compositionLocalOf becomes a performance necessity.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Flutter: The Last UI Framework

As the Flutter framework continues to grow and change, it expands beyond what the team at Google can manage themselves. In this keynote, we’ll discuss how we continue to scale Flutter as a community, what…
Watch Video

Flutter: The Last UI Framework

Chris Sells
Flutter fanatic

Flutter: The Last UI Framework

Chris Sells
Flutter fanatic

Flutter: The Last UI Framework

Chris Sells
Flutter fanatic

Jobs

It’s all about reach

The potential for a full Layout Pass is the highest cost in Compose. Therefore, if a value that changes frequently (Several Writes) also affects dimensions, the need for the targeted invalidation provided by compositionLocalOf becomes even more critical, justifying the acceptance of its small, persistent tracking overhead.

The type of change doesn’t alter the logic of the table; it just adds a massive weight to the “cost” of the Layout-dependent cells. If the change is cheap (Drawing-only), the staticCompositionLocalOf choice in the “Few Writes / Several Reads” cell feels safer. If the change is expensive (Layout-dependent), the cost of using the wrong function skyrockets.

Most guides frame the choice as “static if it rarely changes, dynamic if it changes often.” That sounds simple, but it misses the real lever. What matters most is not the frequency of writes, but the fan-out of reads. If only a few composables consume the value, compositionLocalOf gives you targeted updates. If it’s read across the whole app, staticCompositionLocalOf keeps read overhead low and behaves exactly as you need. And if you ever find yourself with many reads and many writes, the problem isn’t which function to pick — the problem is your design. Narrow the scope, split the locals, or push the state down.

In short: don’t choose based on how often the data changes — choose based on how widely it’s read.

This article was previously published on proandroiddev.com

Menu