Skip to main content

Custom hooks

Once a state hook accumulates a few related useState, useMemoized, and useEffect calls that always travel together, that cluster is a hook of its own. Hooks compose: a hook is just a function that calls other hooks, so you extract the cluster into a useXState and call it like any built-in. This is how utopia_hooks itself is built - useFieldState is a useState for the value plus a useState for the error, wrapped in one function.

When to make a hook vs a plain function

A custom hook is the right tool only when the logic calls hooks. If it does not, it is a normal function - keep it that way.

  • Calls hooks (useState, useMemoized, useEffect, useInjected, …) → custom hook, named useXxx.
  • Pure transformation of values you already have (sorting a list, formatting a date) → plain function, often a named local function inside the hook that needs it.

The naming carries weight: the use prefix is a contract that the function obeys the rules of hooks - it runs during build, and it calls its inner hooks unconditionally and in the same order every time. A function named useXxx that does not call any hooks is misleading; a function that calls hooks but is not named useXxx hides that it must follow those rules.

Extracting a useXState

The mechanics are nothing more than moving hook calls into a function and returning a value. Here is a self-contained counter:

class CounterState {
final int count;
final void Function() increment;
final void Function() reset;

const CounterState({
required this.count,
required this.increment,
required this.reset,
});
}

CounterState useCounterState({int initialValue = 0}) {
final countState = useState(initialValue);

return CounterState(
count: countState.value,
increment: () => countState.value++,
reset: () => countState.value = initialValue,
);
}

Calling useCounterState() runs useState in the caller's hook context, exactly as if the call were inlined. There is no registration step and no special base class - a custom hook is a function. The same rules that govern the Screen / State / View state hook govern it: return a small immutable state object (data + actions), build it once at the end, and keep useProvided / useInjected calls at the top.

Three composition patterns

Extraction shows up in three distinct shapes. The signal that tells them apart is who owns the state - the parent screen, or the widget.

1. Composed hook state - the parent owns it

A reusable widget (a paging control, a date picker) needs state, but the screen needs to react to that state. The rule: the state hook is called from the screen's state hook, and the resulting state object is passed down to the widget. The widget never creates its own state.

Take a paging control. The composable hook owns the page number and exposes navigation actions:

class PagingState {
final int currentPage;
final int totalPages;
final void Function() onNext;
final void Function() onPrevious;

const PagingState({
required this.currentPage,
required this.totalPages,
required this.onNext,
required this.onPrevious,
});

bool get canGoNext => currentPage < totalPages - 1;

bool get canGoPrevious => currentPage > 0;
}

// The composable hook - called from the parent screen's state hook, never from
// inside the widget. The screen owns the page and can react to it.
PagingState usePagingState({required int totalPages}) {
final pageState = useState(0);

return PagingState(
currentPage: pageState.value,
totalPages: totalPages,
onNext: () {
if (pageState.value < totalPages - 1) pageState.value++;
},
onPrevious: () {
if (pageState.value > 0) pageState.value--;
},
);
}

The screen calls it, so it can key its data fetch on the current page - something it could not do if the page lived inside the widget:

class ProductListScreenState {
final List<String>? products;
final PagingState paging;

const ProductListScreenState({required this.products, required this.paging});
}

ProductListScreenState useProductListScreenState({required int totalPages}) {
final paging = usePagingState(totalPages: totalPages); // the screen owns it

final productsState = useAutoComputedState(
() => _service.loadPage(paging.currentPage, pageSize: 20),
keys: [paging.currentPage], // re-fetch when the page changes
);

return ProductListScreenState(
products: productsState.valueOrNull,
paging: paging, // pass the whole state object to PagingWidget
);
}

The widget is then a plain StatelessWidget that receives the whole PagingState:

class PagingWidget extends StatelessWidget {
final PagingState state;

const PagingWidget({super.key, required this.state});


Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: state.canGoPrevious ? state.onPrevious : null,
icon: const Icon(Icons.chevron_left),
),
Text('${state.currentPage + 1} / ${state.totalPages}'),
IconButton(
onPressed: state.canGoNext ? state.onNext : null,
icon: const Icon(Icons.chevron_right),
),
],
);
}
}
caution

Calling usePagingState inside PagingWidget is the common mistake. The screen would then have no way to read the current page or react to a page change. Compose it from the screen's state hook and pass the state object down.

2. Widget-level hook - the widget owns it

When a widget's state is purely its own business - an expand/collapse, a per-item lazy load - and the screen never needs to read it, the widget gets its own hook and the full state/view split at widget scope. This keeps per-item logic out of the screen state entirely.

class ItemTileState {
final String? details;
final bool isExpanded;
final bool isLoadingDetails;
final void Function() onToggle;

const ItemTileState({
required this.details,
required this.isExpanded,
required this.isLoadingDetails,
required this.onToggle,
});
}

ItemTileState useItemTileState({required String id}) {
final isExpandedState = useState(false);
final detailsState = useAutoComputedState(
() => _service.loadDetails(id),
keys: [id],
shouldCompute: isExpandedState.value, // lazy - only load when expanded
);

return ItemTileState(
details: detailsState.valueOrNull,
isExpanded: isExpandedState.value,
isLoadingDetails: isExpandedState.value && !detailsState.isInitialized,
onToggle: () => isExpandedState.value = !isExpandedState.value,
);
}

// The widget calls its own hook and renders - the screen state stays clean.
class ItemTile extends HookWidget {
final String id;

const ItemTile({super.key, required this.id});


Widget build(BuildContext context) {
final state = useItemTileState(id: id);

return ListTile(
title: Text(id),
subtitle: state.isExpanded ? Text(state.details ?? 'Loading...') : null,
trailing: Icon(state.isExpanded ? Icons.expand_less : Icons.expand_more),
onTap: state.onToggle,
);
}
}

The expansion flag and the lazily-loaded details live in the tile. The screen renders a list of ItemTiles and knows nothing about any of it. Use this when the widget has 2+ pieces of local state or runs its own async work, and the screen does not need the result. If the screen does need to coordinate it, that is pattern 1.

3. Screen hook decomposition - splitting a hook that grew too large

A single screen hook can outgrow itself: too many useState calls, unrelated domains (fetching, search, scroll) tangled in one function. Split it into focused sub-hooks that the main hook composes. Each sub-hook returns its own typed state object; the main hook wires one sub-hook's output into the next and never lets them call each other directly.

class OrderScreenState {
final List<String>? orders;
final bool isLoading;
final String searchQuery;
final void Function(String) onSearchChanged;

const OrderScreenState({
required this.orders,
required this.isLoading,
required this.searchQuery,
required this.onSearchChanged,
});
}

// The main hook composes focused sub-hooks instead of holding every concern.
OrderScreenState useOrderScreenState() {
final fetch = useOrderFetchState();
final search = useOrderSearchState(orders: fetch.orders);

return OrderScreenState(
orders: search.filteredOrders,
isLoading: fetch.isLoading,
searchQuery: search.query,
onSearchChanged: search.onQueryChanged,
);
}

The fetch and search concerns each become a self-contained hook:

class OrderFetchState {
final List<String>? orders;
final bool isLoading;

const OrderFetchState({required this.orders, required this.isLoading});
}

OrderFetchState useOrderFetchState() {
final ordersState = useAutoComputedState(() async => ['A-1', 'A-2', 'B-7']);

return OrderFetchState(
orders: ordersState.valueOrNull,
isLoading: !ordersState.isInitialized,
);
}

class OrderSearchState {
final List<String>? filteredOrders;
final String query;
final void Function(String) onQueryChanged;

const OrderSearchState({
required this.filteredOrders,
required this.query,
required this.onQueryChanged,
});
}

// A sub-hook takes another sub-hook's output as input; they never call each
// other directly - the main hook is the coordinator.
OrderSearchState useOrderSearchState({required List<String>? orders}) {
final queryState = useState('');

List<String>? filter() => orders
?.where((it) => it.toLowerCase().contains(queryState.value.toLowerCase()))
.toList();

final filtered = useMemoized(filter, [orders, queryState.value]);

return OrderSearchState(
filteredOrders: filtered,
query: queryState.value,
onQueryChanged: (it) => queryState.value = it,
);
}

The main hook is the only coordinator: it passes fetch.orders into useOrderSearchState as an argument. The sub-hooks stay private to the screen - if one becomes reusable across screens, it graduates to pattern 1. Decompose only when the signals are real (roughly 10+ useState or 300+ lines, or genuinely unrelated domains); a 100-line hook with five useState does not need this.

Per-item state: fixed vs dynamic count

A list where each row has its own state raises one question - is the number of rows fixed at code time, or does it change at runtime?

When it is fixed, no special primitive is needed. Call the per-item hook once per row:

class EditorItemState {
final String label;
final bool isValid;
final void Function() save;

const EditorItemState({
required this.label,
required this.isValid,
required this.save,
});
}

EditorItemState useEditorItemState({required String label}) {
final valueState = useFieldState();

return EditorItemState(
label: label,
isValid: valueState.value.isNotEmpty,
save: () {/* persist valueState.value */},
);
}

class EditorFormScreenState {
final EditorItemState primary;
final EditorItemState secondary;
final bool canSubmit;
final void Function() onSubmit;

const EditorFormScreenState({
required this.primary,
required this.secondary,
required this.canSubmit,
required this.onSubmit,
});
}

// Fixed N: just call the hook once per instance - no useMap, no ceremony.
EditorFormScreenState useEditorFormScreenState() {
final primary = useEditorItemState(label: 'Primary');
final secondary = useEditorItemState(label: 'Secondary');

void submitAll() {
for (final it in [primary, secondary]) {
it.save();
}
}

return EditorFormScreenState(
primary: primary,
secondary: secondary,
canSubmit: primary.isValid && secondary.isValid,
onSubmit: submitAll,
);
}

When the count changes at runtime, reach for useMap, which runs one hook instance per key and keeps each one's state stable across rebuilds. Adding a key initializes a fresh instance; removing one disposes it.

class EditorListScreenState {
final Map<String, EditorItemState> itemStates;
final bool canSubmit;

const EditorListScreenState({required this.itemStates, required this.canSubmit});
}

// Dynamic N: useMap runs one hook instance per key, stable across rebuilds.
EditorListScreenState useEditorListScreenState({required Set<String> itemIds}) {
final itemStates = useMap(
itemIds,
(id) => useEditorItemState(label: id),
);

return EditorListScreenState(
itemStates: itemStates,
canSubmit: itemStates.values.every((it) => it.isValid),
);
}

Reaching for useMap with a fixed, code-time count is overkill - it is the dynamic-count tool. And if the parent never reads per-item state at all, do not lift it to the parent: keep it in a widget-level hook (pattern 2).

Rules for custom hooks

  • Call inner hooks unconditionally and in a stable order. A custom hook is bound by the same call-order rule as any hook - no useState inside an if or a loop. When you need a hook to depend on a condition or a collection, use the control-flow hooks (useIf, useKeyed, useMap) rather than branching around the call. The mechanics behind this rule are covered in Hooks internals.
  • Return a small immutable state object, the same shape a screen state hook returns - data and actions, constructed once at the end.
  • Keep compute logic in named local functions, not private top-level helpers, so they capture the hook's parameters by closure. A local function used this way must stay pure - it must not call hooks itself.
  • Name by behavior. A function that calls hooks is useXxx; a pure transformation is not.

See also