skip to content

In GetX, how do .obs values and the Obx widget make a Flutter widget rebuild, and what must the Obx builder do?

level: juniorimportance: must knowfreq 55%

answer

  1. an extension getter that wraps
  2. RxInt, RxList, Rx<T>
  3. reads tracked while the builder runs
  4. no observable read means an error
  5. equal assignment is ignored; refresh()

basics

~20 s

Calling .obs wraps a value in an Rx type such as RxInt. Obx records every Rx whose value its builder reads while running and rebuilds when one changes; a builder that reads no observable throws GetX's 'improper use' error.

solid answer

~40 s

`.obs` is an extension getter that wraps a value in an observable: `0.obs` is an `RxInt`, `<String>[].obs` an `RxList<String>`, `someObject.obs` an `Rx<T>`. `Obx(() => ...)` is a `StatefulWidget`; while its builder runs, GetX points a static proxy at that Obx's own notifier, so every Rx `.value` read inside registers a subscription, and a later change calls `setState`. Three rules follow. Read the observable directly in the builder, not in a callback that runs later. A builder that reads no observable throws '[Get] the improper use of a GetX has been detected'. And assigning a value equal to the current one is ignored (after the first assignment), so mutating a field inside an `Rx<T>` object needs `refresh()`; `RxList`, `RxMap` and `RxSet` notify from their own mutating methods.

code

dart · 23 lines
dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';

class HabitController extends GetxController {
  final streak = 0.obs; // RxInt
  final habits = <String>[].obs; // RxList<String>

  void checkIn(String habit) {
    habits.add(habit); // RxList notifies by itself
    streak.value++; // assigning a new value notifies
  }
}

class StreakBadge extends StatelessWidget {
  const StreakBadge({super.key});

  @override
  Widget build(BuildContext context) {
    final c = Get.find<HabitController>();
    // both reads happen while the Obx builder runs, so both are tracked
    return Obx(() => Text('Streak ${c.streak.value}, ${c.habits.length} habits'));
  }
}

go deeper

for a junior

Recall that .obs makes an observable, .value reads and writes it, and Obx rebuilds when a value read in its builder changes.

for a middle

Explain the proxy-based tracking during the builder run, the improper-use error, and why in-place object mutation needs refresh().

for a senior

Show you can keep Obx scopes small, spot untracked reads in deferred callbacks, and prefer immutable replacements over refresh() calls scattered through code.

for a principal

Weigh implicit dependency tracking against explicit subscriptions: less boilerplate, but dependencies are invisible in code review and harder to reason about at scale.

## What .obs creates GetX adds an `obs` **extension getter** to common types. It wraps the current value in an **Rx** observable that holds the value and a stream of changes: | Expression | Type | Notifies when | |---|---|---| | `0.obs` | `RxInt` | `.value` is assigned a different int | | `''.obs` | `RxString` | `.value` is assigned a different string | | `<String>[].obs` | `RxList<String>` | a mutating method such as `add` or `[]=` runs | | `Habit('run').obs` | `Rx<Habit>` | `.value` is replaced, or `refresh()` is called | The value is read and written through `.value`. The setter compares the new value with the current one and **ignores an equal assignment** (the very first assignment is the one exception, because of an internal first-rebuild flag), so assigning the same int twice does not rebuild anything. ## How Obx subscribes `Obx(() => Text('${c.streak.value}'))` is a `StatefulWidget` whose state owns a private notifier. When it builds: 1. GetX stores that notifier in a static **proxy** slot. 2. It runs your builder. Every Rx getter called during that run adds its stream to the proxy, which subscribes the Obx to it. 3. It restores the previous proxy and checks that at least one observable was registered. 4. Later, when any registered Rx emits, the Obx calls `setState` and rebuilds. There is no dependency list to maintain; the builder's reads **are** the dependency list. That is GetX's main selling point: add `.obs`, wrap the widget in `Obx`, and it updates. ## The three rules that trip people up - **Read inside the builder's own run.** Only reads that happen synchronously while the Obx builder executes are tracked. A read inside a callback that executes later, such as an `onPressed` or a `ListView.builder` `itemBuilder` that runs during layout, happens after the proxy was restored, so it is not tracked by the outer Obx. - **Read at least one observable.** If the builder reads none, GetX throws: '[Get] the improper use of a GetX has been detected. You should only use GetX or Obx for the specific widget that will be updated.' The usual cause is reading a plain field, or reading the Rx outside the builder and passing a plain value in. - **Replace, or refresh, mutable objects.** For `final habit = Habit('run').obs`, writing `habit.value.name = 'swim'` changes the object without assigning `.value`, so nothing emits. Call `habit.refresh()` afterwards, or assign a new object. Collections are easier: `RxList.add`, `remove` and `[]=` call `refresh()` themselves. ## Scope of a rebuild An Obx rebuilds only its own builder. Wrap the **smallest** widget that shows the value; a single Obx around a whole screen rebuilds the whole screen on every change. The error message says the same: if a parent and a child both need updates, give each its own Obx. ## Related widgets - `ObxValue((data) => Switch(value: data.value, onChanged: (v) => data.value = v), false.obs)` holds a local observable for small toggles. - `GetX<T>(builder: (controller) => ...)` is a heavier reactive widget that can also create and remove a controller. - Workers such as `ever`, `once`, `debounce` and `interval` react to an Rx outside the widget tree, for example to trigger work when a value changes. ## Debugging an Obx that does not update Work through the chain from write to rebuild: - **Was anything emitted?** An equal assignment is ignored, and an in-place mutation of an `Rx<T>` object emits nothing without `refresh()`. - **Was the read tracked?** The read must happen while the Obx builder runs; reads in deferred callbacks, or values copied out of the Rx before the builder, are invisible to it. - **Is it the same instance?** If two controllers of one type exist under different tags, the widget may be observing the other one. - **Is the Obx still mounted?** A disposed Obx cancels its subscription; a rebuilt parent may have replaced it. A quick probe is `ever(c.streak, (v) => debugPrint('streak $v'))` in the controller's `onInit`: if it prints but the widget does not change, the problem is tracking, not emission. ## A short checklist - Declare observables as `final` fields on a controller: `final streak = 0.obs;`. - Read `.value` (or a collection getter such as `length`) inside the Obx builder. - Mutate collections with their methods; call `refresh()` after mutating an object in place. - Keep each Obx small.

  • Why does an Obx wrapping a ListView.builder not rebuild when an item's observable changes?
    `itemBuilder` runs later, during layout, after the Obx builder has finished and the proxy has been restored, so reads inside it are not tracked by that Obx. Either read the observable (for example the list's `length`) in the Obx builder itself, or wrap each item's content in its own Obx.
  • A controller holds final user = User('Ana').obs and sets user.value.name = 'Eva'; why does the UI not change, and what fixes it?
    The object was mutated in place and `.value` was never assigned, so the Rx emits nothing. Call `user.refresh()` after the mutation, or assign a new object, for example `user.value = User('Eva')`. With an immutable model, replacing the object is the natural habit.

saying these in an interview costs you the question

  • Obx rebuilds whenever any field of the controller changes
  • Any .value read in a callback inside the Obx is tracked too
  • Mutating a field inside an Rx<T> object notifies automatically
  • An Obx that reads no observable simply never rebuilds
  • Wrapping the whole screen in one Obx is the recommended pattern