We've all been there: you're excited to build a new feature, but as soon as you dive in, you realize the existing code is a mess.
At this point, you have two choices:
- Charge ahead—make it work by building on top of the existing code.
- Take a step back—clean things up first, then implement the feature with confidence.
Option 1 might feel faster, but it usually leads to disaster:
- Your new code works… kind of. But it doesn't feel right—hello, tech debt.
- You’re unsure if everything plays nicely together, and subtle bugs creep in.
- Your PR balloons in scope, making reviews painful and frustrating.
Meanwhile, your boss (who is very easily impressed by those wonderful AI agents) is breathing down your neck and questioning your abilities. 😭😱
Before you quit tech and become a farmer (no shame in that), let me share a story about refactoring—one that helped me ship a feature smoothly instead of wrestling with broken code.
As part of this, we'll explore a real-world example of:
- ephemeral vs application state in Flutter
- how to manage navigation and communication across screens
- how to blend reactive updates with imperative APIs (as needed by ScrollControllers)
A Tale About Refactoring
Recently, I decided to add wide-screen support to my Flutter tips app:
That is, given these two screens that were initially designed for mobile:
I wanted to show them side by side and enable seamless navigation on iPad:
Making this UI adaptive is quite easy. All you need is:
- A responsive layout that uses a split-view on wide screens, and regular push/pop navigation on mobile.
- Some conditional logic based on
MediaQuery.sizeOf(context)
orLayoutBuilder
to choose between the split-view and single-pane layout depending on a layout breakpoint.
When given a task like this, it's tempting to just add the new feature as a single PR.
But, as we're about to find out, there's more than meets the eye.
Ephemeral State, Application State, and Navigation
To set the stage, let's revisit the mobile-only version of the app:
Based on the screenshots above, can you guess how I was keeping track of the currently selected tip index?
Of all possible options, I decided to go with the good old setState
approach.
That is, I had a _MarkdownTipsPageViewState
class that I was using for:
- storing the
_currentTipIndex
as ephemeral state - updating this state via the
_updateTipIndex
method
class _MarkdownTipsPageViewState extends ConsumerState<MarkdownTipsPageView> {
// both initialized in initState
late PageController _pageController;
late int _currentTipIndex;
void _updateTipIndex(int tipIndex) {
setState(() {
_currentTipIndex = tipIndex;
// Used to store the tip index in shared preferences (but not too relevant here)
ref.read(currentTipIndexStoreProvider.notifier).setTipIndex(tipIndex);
});
}
...
}
In the build
method, I had an IconButton
with a callback for pushing the tipsList
route, and updating the state if a new tip index was returned:
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search tips',
onPressed: () async {
// Push the "tips list" route and wait for the result
final newIndex = await Navigator.of(context).pushNamed<int>(
AppRoutes.tipsList,
arguments: _currentTipIndex,
);
// If a new tip index is returned
if (newIndex != null) {
// Update the state and jump to the new page
_updateTipIndex(newIndex);
_pageController.jumpToPage(currentPage);
}
},
)
Accordingly, my TipsListViewScreen
had an onSelected
callback that was used to return the selected tip index via Navigator.pop
:
TipsListViewScreen(
flutterTips: tips,
initialTipIndex: initialTipIndex,
// Pop the index so the parent route can update the state
onSelected: (index) => Navigator.of(context).pop(index),
)
Here's a diagram showing the interaction between the two screens:
In summary:
- The
_currentTipIndex
variable is the main source of truth that determines the currently selected tip. - It is stored in the
_MarkdownTipsPageViewState
class, but it is also passed to theTipsListViewScreen
via theinitialTipIndex
argument and updated via theonSelected
callback.
This works just fine on mobile, but let's now consider the wide-screen version of the app:
With the setup described above, the app would behave like this:
- When swiping between pages, the
_currentTipIndex
would be updated in the page view screen, but this change would not be reflected in the list view screen. - Calling
Navigator.pop
from the list view would break the navigation completely (in split view mode, we're already at the root of the navigation stack).
Such inferior and broken UX is absolutely unacceptable. So how did I fix it?
From Ephemeral to Application State
The core issue was that my _currentTipIndex
lived as ephemeral state inside a widget, making cross-screen updates clunky.
Here's a better approach:
- Promote the current tip index to application state
- Use a
ValueNotifier
or Riverpod's ownNotifier
class to store it
This way, both pages can mutate the notifier and update the UI when needed. Here's an interactive demo of the solution in action:
It works like this:
- When you swipe between pages, the correct item is highlighted and centered in the list.
- When you select an item from the list, the page view immediately jumps to the corresponding tip.
Much better!
You can make this work by using
ValueNotifier
or Riverpod's ownNotifier
class. I won't cover all the details here, but you can take this challenge as an exercise if you want: Page View / List View syncing challenge
What About Navigation?
On mobile, we can use the search icon to navigate to the tips list screen:
But on iPad, this icon is hidden since both pages are visible side by side:
To achieve this, I ended up using a small LayoutBreakpoints
helper class:
if (!LayoutBreakpoints.isSplitView(screenSize))
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search tips',
// Push the tips list screen (application state is updated elsewhere)
onPressed:
() => Navigator.of(context).pushNamed(AppRoutes.tipsList),
),
Note how the onPressed
callback is no longer used to update the tip index.
In fact, we can now do that directly on the onSelected
callback:
TipsListViewScreen(
flutterTips: tips,
currentTipIndex: currentTipIndex,
onSelected: (index) {
// Update the tip index
ref
.read(tipUpdateEventNotifierProvider.notifier)
.updateTipSelectedFromList(index);
// On mobile, go back to the tips page view
if (!LayoutBreakpoints.isSplitView(screenSize)) {
Navigator.of(context).pop();
}
},
)
The fundamental idea is that neither screen owns the application state, but both screens can mutate it and rebuild / update their UI when it changes:
Considerations About ScrollControllers
Promoting the tip index to application state is a good step forward, but it's not enough:
- On the page view screen, the currently selected page is controlled by a
PageController
, so an explicit call tojumpToPage
is needed to ensure it stays in sync with the tip index. - On the list view screen, the currect offset is controlled by a
AutoScrollController
(from thescroll_to_index
package). This controller needs to be updated explicitly when the tip index changes.
Implementing state updates and ensuring these were correctly reflected in the UI required some fine-tuning. But eventually, I managed to get the app to work as expected.
Hint: you can register a
ValueNotifier
listener to update thePageController
orAutoScrollController
when the tip index changes, as explained here: Side Effects with ValueNotifier.
Time for some advice about refactoring code. 👇
Refactor First, Then Add Features
My initial goal was to add responsive support to my Flutter tips app:
Rather than tackling everything at once, I took a step back and did a first round of refactoring:
- moved from ephemeral to application state (via notifier)
- used the notifier to trigger state updates rather than relying on push/pop navigation
- prepared the ground for the new feature
After verifying that the updates worked as expected, I landed this change.
Then, in a second PR, I implemented the actual feature:
- wide-screen support via split view
- a proper event system for syncing the scroll position between screens
- final tweaks
By refactoring first, I was able to revisit the initial assumptions and choose a more suitable state management approach. But if I had tried to do everything at once, I would have ended up with workarounds and ugly code.
The Case for Refactoring
Refactoring is about making future changes easier. When done right:
- New features integrate smoothly instead of feeling bolted on.
- Tech debt is reduced, making maintenance less painful.
- Code reviews are faster, since changes are smaller and more focused.
Some even argue that refactoring should be part of your definition of done for every feature.
But let's face it: as software evolves, requirements change and your initial assumptions may no longer hold. When that happens, refactoring is a good way to improve the existing foundations and make way for new code.
Of course, refactoring can be done with more confidence if you have automated tests that help you catch regressions. This is especially important when you work on a shared codebase with other developers.
But for today, here's the key takeaway:
→ If you're fighting against existing code when making changes, stop and refactor first.
You’ll save time, avoid frustration, and ship with confidence.
Conclusion
Adding new features to a messy codebase is like building a house on a shaky foundation—it might stand for a while, but cracks will show. Refactoring first ensures that your code remains flexible, maintainable, and ready for future improvements.
So next time you're about to add a feature, ask yourself: Is my code ready for this? If not, take a step back, clean it up, and then move forward. Your future self (and your team) will thank you.
P.S. You can try the updated Flutter Tips app with responsive layouts here:
Happy coding!