Why You Should Refactor Before Adding New Features

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:

  1. Charge ahead—make it work by building on top of the existing code.
  2. 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. 😭😱

The boss from Office Space (movie)

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:

Flutter tips app preview
Flutter tips app preview

That is, given these two screens that were initially designed for mobile:

Flutter tips app on mobile, with two screens for content and tips navigation
Flutter tips app on mobile, with two screens for content and tips navigation

I wanted to show them side by side and enable seamless navigation on iPad:

Flutter tips app on iPad, with content and navigation side by side
Flutter tips app on iPad, with content and navigation side by side

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) or LayoutBuilder 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:

Flutter tips app on mobile, with two screens for content and tips navigation

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:

Interaction between the page view and list view screens
Interaction between the page view and list view 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 the TipsListViewScreen via the initialTipIndex argument and updated via the onSelected callback.

This works just fine on mobile, but let's now consider the wide-screen version of the app:

Flutter tips app on iPad, with content and navigation side by side

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 own Notifier 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:

Page View - List View syncing demo (looks best on desktop)

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 own Notifier 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:

Flutter tips app on mobile, with two screens for content and tips navigation

But on iPad, this icon is hidden since both pages are visible side by side:

Flutter tips app on iPad, with content and navigation 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:

Interaction between the page view, list view, and current tip index notifier
Interaction between the page view, list view, and current tip index notifier

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 to jumpToPage 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 the scroll_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 the PageController or AutoScrollController 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:

Flutter tips app on iPad, with content and navigation side by side

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!

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter In Production

Flutter In Production

Learn about flavors, environments, error monitoring, analytics, release management, CI/CD, and finally ship your Flutter apps to the stores. 🚀

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Last updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.