State Management

Managing state in your Nocterm apps

State is data that changes over time. This guide shows you how to manage state in Nocterm apps using setState() and other patterns.

Using setState

The setState() method tells Nocterm to rebuild your component with new state.

Basic usage

Call setState() with a function that updates your state:

class Counter extends StatefulComponent {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Component build(BuildContext context) {
    return Focusable(
      focused: true,
      onKeyEvent: (event) {
        if (event.logicalKey == LogicalKey.space) {
          _increment();
          return true;
        }
        return false;
      },
      child: Text('Count: $_count'),
    );
  }
}

When you call setState():

  1. The function you pass runs and updates state
  2. Nocterm marks the component as needing rebuild
  3. The build() method runs with the new state
  4. The UI updates

What setState does

setState() schedules a rebuild. It doesn't rebuild immediately—Nocterm batches updates for efficiency.

Update state inside the setState() function:

setState(() {
  _count++;
});

The function you pass to setState() should be fast. Don't do expensive work inside it.

When to call setState

Call setState() whenever you change state that affects the UI:

  • User input changes a value
  • A timer fires and updates time
  • An async operation completes and returns data
  • Any mutable field in your State class changes

Don't call setState() during build(). This creates an infinite loop.

setState and async operations

You can call setState() after async operations:

void _loadData() async {
  final data = await fetchData();
  if (mounted) {  // Check if still in tree
    setState(() {
      _data = data;
    });
  }
}

Always check mounted before calling setState() in async callbacks. The component might be removed from the tree before the operation completes.

Lifting state up

When multiple components need the same state, move it to their common parent.

Example: Shared counter

Two components that both need a counter:

class App extends StatefulComponent {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  int _count = 0;

  void _increment() {
    setState(() => _count++);
  }

  void _decrement() {
    setState(() => _count--);
  }

  @override
  Component build(BuildContext context) {
    return Column(
      children: [
        CounterDisplay(count: _count),
        SizedBox(height: 1),
        CounterControls(
          onIncrement: _increment,
          onDecrement: _decrement,
        ),
      ],
    );
  }
}

class CounterDisplay extends StatelessComponent {
  const CounterDisplay({required this.count, super.key});

  final int count;

  @override
  Component build(BuildContext context) {
    return Text('Count: $count');
  }
}

class CounterControls extends StatelessComponent {
  const CounterControls({
    required this.onIncrement,
    required this.onDecrement,
    super.key,
  });

  final VoidCallback onIncrement;
  final VoidCallback onDecrement;

  @override
  Component build(BuildContext context) {
    return Focusable(
      focused: true,
      onKeyEvent: (event) {
        if (event.logicalKey == LogicalKey.arrowUp) {
          onIncrement();
          return true;
        } else if (event.logicalKey == LogicalKey.arrowDown) {
          onDecrement();
          return true;
        }
        return false;
      },
      child: Text('Use arrows to change'),
    );
  }
}

State lives in the parent. Children receive data through parameters and send changes up through callbacks.

When to lift state

Lift state when:

  • Multiple components need the same data
  • Components need to communicate with each other
  • You want to share state across different parts of the UI

Keep state as low in the tree as possible. Only lift it when necessary.

State best practices

Initialize state in initState

Use initState() to set up state that depends on context or needs initialization:

@override
void initState() {
  super.initState();
  _controller = TextEditingController();
  _timer = Timer.periodic(Duration(seconds: 1), _onTick);
}

Always call super.initState() first.

Clean up in dispose

Use dispose() to release resources:

@override
void dispose() {
  _controller.dispose();
  _timer.cancel();
  super.dispose();
}

Always call super.dispose() last.

Keep state minimal

Only store data that affects the UI. Don't store derived values:

// Good
class _CounterState extends State<Counter> {
  int _count = 0;

  @override
  Component build(BuildContext context) {
    final isEven = _count % 2 == 0;  // Calculate in build
    return Text('Count: $_count (${isEven ? 'even' : 'odd'})');
  }
}

// Bad - don't store derived values
class _CounterState extends State<Counter> {
  int _count = 0;
  bool _isEven = true;  // Derived from _count

  void _increment() {
    setState(() {
      _count++;
      _isEven = _count % 2 == 0;  // Extra work to keep in sync
    });
  }
}

Avoid setState in build

Never call setState() during build():

// Wrong - infinite loop
@override
Component build(BuildContext context) {
  setState(() => _count++);  // Don't do this!
  return Text('Count: $_count');
}

If you need to respond to changes, use lifecycle methods like didUpdateComponent() or didChangeDependencies().

Advanced: Riverpod

For complex state management, Nocterm supports Riverpod. Riverpod provides:

  • Global state accessible from anywhere
  • Reactive dependencies
  • Automatic cleanup

This is an advanced topic. For most apps, setState() and lifting state up are sufficient.

See the Riverpod documentation for details on using providers with Nocterm.

Next steps