NStater — lightweight state management with zero third-party dependencies
NStater is a minimal and fast way to manage state in Flutter. You control the lifecycle of controllers and reactive values yourself, using your own types and subscriptions.
📦 Features
- NVar — a typed reactive variable that notifies listeners about changes
- NField — a widget that listens to an
NVarand rebuilds only when the value actually changes - NController — a base state controller class
- NState — widget that creates a controller and manages its lifecycle
- N.services — a service locator for application-owned dependencies
✨ Additional Features
- Custom equality — control when values are considered equal to optimize rebuilds
- Selective rebuilds — rebuild only when specific parts of state change
- No dependency on
ChangeNotifier/ValueNotifier - You can combine multiple
NVarorNControllerinstances on a single screen
🔍 Quick API
N.services
Register and retrieve application-owned services without factories:
await N.services.register<ApiClient>(
ApiClient(),
dispose: (client) => client.close(),
);
await N.services.registerAsync<Database>(
Database.open,
dispose: (database) => database.close(),
);
final api = N.services.get<ApiClient>();
final database = await N.services.getAsync<Database>();
The combination of service type and optional name identifies a registration:
await N.services.register<ApiClient>(
ProductionApiClient(),
name: 'production',
);
final api = N.services.get<ApiClient>(name: 'production');
Registering the same type and name again is a normal replacement operation. The old service is disposed, the new one is installed, and a replacement event is emitted:
N.services.events.listen((event) {
print('${event.type}: ${event.serviceType} (${event.name})');
});
await N.services.register<ApiClient>(newClient);
Services using the NService mixin are marked with isGlobal = true when they
are registered. NController includes this contract automatically. Services
implementing NDisposable are also disposed automatically; other services can
provide a synchronous or asynchronous dispose callback. Registry events
contain only type and name metadata, and service instances are never written to
the diagnostic log.
final api = N.services.tryGet<ApiClient>();
final registered = N.services.isRegistered<ApiClient>();
await N.services.unregister<ApiClient>();
await N.services.reset();
During asynchronous replacement, synchronous get<T>() continues returning the
old ready service. getAsync<T>() waits for the pending replacement. If no old
instance exists yet, get<T>() throws a StateError with guidance to use
getAsync<T>().
NVar<T>
Reactive value with subscribe / unsubscribe:
final n = NVar<int>(0);
n.addListener((v) => print('new value: $v'));
n.value = 42;
// new value: 42
print(n.value);
// 42
Custom equality:
// Use custom comparison for complex objects
final items = NVar<List<String>>(
['a', 'b'],
isEqual: (old, new) => ListEquality().equals(old, new),
);
items.value = ['a', 'b']; // Won't notify listeners (content is equal)
NField<T>
A widget that listens to an NVar and rebuilds only when the value actually changes:
NField<int>(
data: counter,
builder: (v) => Text('$v'),
);
Selective rebuilds:
class User {
final String name;
final int age;
User(this.name, this.age);
}
final userVar = NVar<User>(User('John', 30));
// Rebuild ONLY when name changes, ignore age updates
NField<User>(
data: userVar,
shouldRebuild: (prev, curr) => prev.name != curr.name,
builder: (user) => Text(user.name),
);
NController
Base controller class with subscriptions and no dependencies:
class CounterController extends NController<CounterController> {
int count = 0;
void increment() {
count++;
update(); // Notify all listeners
}
@override
void onInit() {
// Called before the first widget build
print('Controller initialized');
super.onInit();
}
@override
void onReady() {
// Called after the first frame
print('Controller ready');
super.onReady();
}
@override
void dispose() {
// Clean up resources
print('Controller disposed');
super.dispose();
}
@override
void beforeMount() {
// Called before onInit
print('Controller before mount');
super.beforeMount();
}
}
NState<C extends NController<C>>
Builds UI based on a controller and automatically creates and disposes it:
NState<CounterController>(
create: () => CounterController(),
builder: (controller) => Column(
children: [
Text('Count: ${controller.count}'),
ElevatedButton(
onPressed: controller.increment,
child: Text('Increment'),
),
],
),
);
The controller is created once for the lifetime of the NState element. Use a
different widget key when a new controller instance is required.
NState can also use a controller owned by N.services:
NState<AuthController>(
create: N.services.get<AuthController>,
builder: (controller) => AccountScreen(controller: controller),
);
The registry sets controller.isGlobal to true. NState still subscribes and
runs its widget lifecycle callbacks, but it never disposes a global controller.
Only its external owner can destroy it through N.services.unregister, service
replacement, or N.services.reset().
NVarCombiner<R>
Combine multiple NVar sources into a single computed value that automatically updates when any source changes:
final firstName = NVar<String>('John');
final lastName = NVar<String>('Doe');
// Combine two sources
final fullName = NVarCombiner(
[firstName, lastName],
() => '${firstName.value} ${lastName.value}',
);
print(fullName.value); // John Doe
firstName.value = 'Jane';
print(fullName.value); // Jane Doe
Form validation example:
final email = NVar<String>('');
final password = NVar<String>('');
final acceptTerms = NVar<bool>(false);
final isFormValid = NVarCombiner(
[email, password, acceptTerms],
() => email.value.contains('@') &&
password.value.length >= 6 &&
acceptTerms.value,
);
// Use in UI
NField<bool>(
data: isFormValid,
builder: (isValid) => ElevatedButton(
onPressed: isValid ? _submit : null,
child: Text('Submit'),
),
);
⚡ Performance Tips
- Use
shouldRebuildinNFieldto prevent unnecessary rebuilds when only specific parts of data change - Use
isEqualinNVarfor deep equality checks on complex objects (lists, maps) - Dispose resources — always call
super.dispose()in controllers anddispose()onNVarinstances - Combine multiple
NVar— use separate reactive variables for independent state instead of one large object
📚 Lifecycle Methods
NController
beforeMount()— called before controller initializationonInit()— called before the first widget buildonReady()— called after the first frame is completedispose()— called when widget is removed from tree
NVar / NVarCombiner
dispose()— unsubscribe from all listeners and clean up resources
Disposal is terminal. After dispose(), new listeners and value updates are
ignored. Listener exceptions are reported through Flutter's error reporting
pipeline without preventing the remaining active listeners from running.
Use isDisposed when owner code needs to check whether an NVar or
NController is still active. NVarCombiner follows the same terminal
disposal contract and also accepts an optional isEqual callback for computed
result equality.
📄 License
MIT License — see LICENSE file for details.
🤝 Contributing
Issues and pull requests are welcome! Visit GitHub repository.