DEV Community

Cover image for Mixing state and logic is an anti-pattern
Mikhail Palei
Mikhail Palei

Posted on • Updated on

Mixing state and logic is an anti-pattern

The art of programming is the art of managing data. Take the Facebook app for example: there are user posts, direct messages, list of friends, friend requests and so on. Does it really matter how does the “send” button in a chat looks like? Does it really matter if you can scroll user posts vertically or horizontally? Does it really matter if notifications indicator is to the right or to the left?

On one hand things like these of certainly do matter. On the other hand it is easy to see that these are mere details. If we strip all such details away the only thing that is left is the core of any application: the data and how we manage it. For instance: when we send a message to a friend two things are going to happen: 1) a network request is going to ask an API to save a message somewhere in a database and 2) our message is going to be displayed at the bottom of the chat page. In the first action we take some data and send it over the network. In the second action we take some data and display it to the user. In both scenarios we basically manage data. We take data and do something with it.

So how does the State Management is related to this observation? Well, State Management actually means “Management of the State of Data”. Or “Data Management” in short. This basically means that “State Management” is the same as “Data Management”. These phrases are interchangeable.

The problem is people never make this connection. When people think of “state management” they think of all kinds of things but mainly they think that states exist to hold logic (business rules of the application) and/or to “glue” together logic and UI. This leads to a situation where state management is treated as an afterthought. Because why would anyone care about a “glue layer” of an application? It is considered insignificant.

But when we change “state management” to “data management” the importance of this layer immediately becomes apparent. You can’t treat your data as an afterthought. It is essential.

Development of applications is a hard process. It is especially complicated when there are multiple teams and hundreds of thousands of lines of code involved. In my opinion one the biggest reasons (if not the biggest) it is hard because no one does data management properly. And as I mentioned data management is the most fundamental part of software engineering.

So why were this article written? In my 10 years of programming I have never seen any team, any developer or even a single article that would do state management properly.

Today we are going to talk about fundamental mistake of state management: unpredictable states. A problem that occurs when logic and states are mixed together.

What is the problem?

In order to understand the problem let me explain by an example. Lets say we are working on an ordinary chat application: the app has chat rooms and each chat room has a list of messages. When a chat room is opened we need to download latest messages and display them to the user.

Here is how state management is usually done in this case:

  1. When a user lands on the page some kind of state method is called: chatRoomState.getLatestMessages();
  2. Inside of this method there is usually a Use Case or a Controller:

    class ChatRoomState {
        getLatestMessages () async {
            final downloadedMessages = await GetChatRoomMessagesUseCase.call();
            // Next line sets the new state and notifies all listeners about it.
            state.latestMessages = downloadedMessages;
        }
    }
    
  3. Inside of this Use Case we usually call a repository:

    class GetChatRoomMessagesUseCase {
        call() async {
            return await getChatRoomMessagesRepository.call();
        }
    }
    
    class GetChatRoomMessagesRepository {
        call() async {
            return await http.get('/chat-room/123/messages');
        }
    }
    

Here is a diagram of that process:

A diagram where a state calls a use case that calls a repository.

Note: if you don’t know what a Use Case or a Repository is think of it simply as classes that do something. In our example state methods call for classes that in turn call other classes. There is an obvious nesting going on.

So what is the problem? Let’s say we received a task to display a “Messages loaded successfully” snack bar after messages were downloaded. It might not be great for user experience but as a developers we do not decide such things we simply need to implement the requirement. Question is: where would you put that code?

Activating a snack bar inside of a ChatRoomState method does not seem like a good idea. Chat room data and snack bars should not be mixed. Putting a snackbar inside of a Repository is a no-no because repositories must perform only a single task. Perhaps we should put in a UseCase? That seems like a good idea. Consider this example:

class GetChatRoomMessagesUseCase {
    call() async {
        final messages = await getChatRoomMessagesRepository();
        Snackbar(message: 'Messages loaded successfully').display();
        return messages;
    }
}
Enter fullscreen mode Exit fullscreen mode

At first it seems like the case should be closed. But there are a few problems though. First, extending behavior of nested classes is hard. Let’s say in our next task we need to add loading of messages to a one more page. There is a problem though, on the second page there is a specific requirement NOT to display a snack bar if downloading was successful. Obviously this problem is not a huge one. We simply need to provide an argument to our classes like so:

class ChatRoomState {
    getLatestMessages ({bool? isSnackbarHidden}) async { // <- Notice the new argument
        final downloadedMessages = await GetChatRoomMessagesUseCase.call(
            isSnackbarHidden: isSnackbarHidden, // <- Notice the new argument
        );
        state.latestMessages = downloadedMessages;
    }
}

class GetChatRoomMessagesUseCase {
    call({bool? isSnackbarHidden}) async { // <- Notice the new argument
        final messages = await getChatRoomMessagesRepository();
        if (isSnackbarHidden == false) {
            Snackbar(message: 'Messages loaded successfully').display();
        }
        return messages;
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see the code is getting much complicated rather quickly. Mind you our example is only two layers deep. It is not uncommon to have 3-4 layers of nested classes.

However that is not the worst part of the problem. Let’s say a few months went by and the app grew larger. A new person was hired and he have been tasked to load the messages yet in another page. The guy uses the ChatRoomState.getLatestMessages and is about to mark the task as “Done” but somehow the application produces a snack bar with the message “Messages loaded successfully”. The person would be surprised of course because this is an unexpected side effect.

Just think about it for a second: a ChatRoomState class should only be responsible for holding data of a selected chat room. Somehow when we work with the chat room data there is a side effect that produces a snack bar. What I am saying is this: working with a chat room data somehow affects unrelated UI pieces.

Such problems are always unobvious and it takes time to:

  • spot them
  • debug the reason
  • fix the problem

You may think that this problems are being blown out of proportion. But the problem is more severe than it may seem.

There were a curios case in one of the projects I worked on years ago. We were working on an application that had 3 big features. With time our managers noticed that one of the features was used many many times more frequently than the other 2. So we started changing the app to make that feature the focal point of our application. Basically we stopped working on the other features completely.

Can you imaging our horror when we found out that the feature was not actually used as often as we thought. One of the state methods that was used all over the application was producing an undetectable side effect. Basically every time any page was loaded a “Feature X was used” analytics event was recorded and send to the API.

This simple side effected costed us months of work and incalculable amount of lost profits.

The solution

So how does one tackle this problem? By extracting logic out of the state. Basically we must turn this:

Here previous image is displayed again.

Into this:

A diagram where a use case calls both repository and state.

First, lets rewrite our state:

class ChatRoomState {
    setMessages(messages) {
        // Next line sets the new state and notifies all listeners about it.
        state.latestMessages = messages;
    }
}
Enter fullscreen mode Exit fullscreen mode

Second, lets place extracted logic inside the use case:

class GetChatRoomMessagesUseCase {
    call({bool? shouldDisplaySnackbar}) async {
        final downloadedMessages = await getChatRoomMessagesRepository();
        chatRoomState.setMessages(downloadedMessages);

        if (shouldDisplaySnackbar == true) {
            Snackbar(message: 'Messages loaded successfully').display();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the end everything that must happen for a particular action (downloading the chat messages in our case) was moved into a single container (the use case). This way our action is extremely easy to read, to understand, to reuse, to extend, to test and to maintain. Consider this: if you ever need to change something about downloading the messages you simply go to the appropriate use case and apply the changes without diving into multiple scattered around classes.

Want to sort the messages? Want to display something else instead of a snack bar? Want to add analytics tracking? Want to change the source of the messages? All of that is easy to do because you know where to look and you are always sure that the possibility of breaking something because of a side effect is extremely low. What you see in the use case is what you get in the UI. No side effects, no unexpected results. No way of shooting yourself in the foot.

This is called Predictable State Management.

Rules of Predictable State Management

In order to implement and efficiently use Predictable State Management lets define some rules:

Don’t create a state unless you need a single source of truth

Most of the people do not know or do not remember why state management was invented in the first place. Let’s say you are working on a Facebook-like website. Every few minutes you fetch data about how many friend requests a user have. You need to display this data in 3 places:

  • In the top right corner where the notification icon is displayed. Number of friend requests == number of notifications.
  • In left side menu there is a “Friends” link with a number of incoming friend requests.
  • If you are on “Friends” page of the website you need to display number of friend requests in the body of the page as well.

So what is the problem? Well every time you fetch the new version of data you need to somehow update UI in a 3 separate places. That code gets complicated and unmaintainable fast because:

  • You should know what page you are on.
  • You should know how many places of the page are using that data.
  • You change visual elements manually via various scripts. Every place has its update script.

In order to solve that problem states and state management were invented. It was a simple idea really: UI pieces should listen to special data classes. UI should change automatically when data changes.

This allowed developers to have a Single Source of Truth. Update a single class once and all of the UI pieces will update automatically. That is it! Nobody tried to recreate the wheel. Nobody tried to create a “States are glue between UI and logic”-like concepts. We simply wanted to create a good way to reuse data.

There is an important note though. “Single source of truth” does not mean “a state that is used in multiple places”. If a certain data is used only in a single place it is still a valid option to use state management for it. The reason behind it is simple: in the future you might want to use the data in multiple places. This will allow to simply “tap into” the state without rewriting a lot of code.

This rule simply means that unnecessary state should not be created. Here is an example of unnecessary state:

// BAD. Do not do this.
class AnalyticsState {
    trackAnalyticsEvent(event) {
        AnalyticsService().trackEvent(event);
    }
}
Enter fullscreen mode Exit fullscreen mode

This state is unnecessary because it does not contain any data and it does not modify any data. You will gain nothing by subscribing to this state. In these situations you should call AnalyticsService directly without wrapping it with a state.

1 data point per state. Plus optionally additional meta data about it

One of the most common mistakes of state management is to create a separate state for each page of the application. Basically developers put every single piece of data for a single page of application into the same container. Lets discuss an example:

// BAD. Don't do this.
class MainPageState {
    User viewer;
  List<ChatRoom> chatRooms;
    boolean isSideBarOpen;
    boolean isCreateChatRoomDialogOpen;

    // Some setters/getters/other methods would be written below this line.
}
Enter fullscreen mode Exit fullscreen mode

Why is it a bad example? Well there are many reasons:

  • This makes data non-reusable. Let’s say in our application we want to add an ability to create chat rooms not only in the main page of the app but also somewhere else. In this case we would have to copy and paste boolean isCreateChatRoomDialogOpen; to some other state. This would be code duplication and breaking of DRY principle.
  • This breaks SOLID principles. The Single Responsibility principle is broken because there are multiple reasons to change a class. You want to change something about viewer data? Change MainPageState. Want to change something about chatRooms list? Change the MainPageState again. Also the Open Closed principle is being violated. If you want to add new features/new data points to the main page you will have to add code to MainPageState instead of creating separate classes.

Overall the whole state would benefit if every single property of MainPageState would be extracted into a separate state:

class ViewerState {}
class ChatRoomsState {}
class SideBarState {}
class CreateChatRoomDialogState {}
Enter fullscreen mode Exit fullscreen mode

This leads us to a conclusion: a good state is a state that contains a very small yet reasonable amount of data.

What does the “small yet reasonable” mean? Let’s take information about currently logged in user as an example. Having a “ViewerState” which contains a “User” object inside of it is reasonable. If we add “userHasOpenedLoginDialog” that would be wrong because this data can be safely extracted into a separate state. When we think of a “user” we never think about opened dialogs, rather we think about things like “first name”, “last name” or an “email”. Therefore it is reasonable to have a “User” object inside of a “ViewerState”.

On the other hand if we try to break down “ViewerState” into smaller “ViewerFirstNameState” and “ViewerLastNameState” it would be unreasonable because breaking a simple object into multiple states would give us no benefit. On the contrary: it will create too many maintenance problems.

I am NOT saying that a single object can not be broken down into smaller pieces. If your object grows too big you should definitely break it down. All I am saying is in our case it is not needed.

Optional additional metadata.

As the rule states we also have an ability to include optional additional metadata into the state. But what actually is metadata? Well, it is "data that provides information about the data”. Basically a lot of times when we, for instance, want to load some data we want to know information about that data. Is it currently loading? Has the loading end up with the error? How many times have we tried to request the data after a failure?

Please consider this example:

// Complex state with loadable data.
class ChatRoomsState {
    // Meta data.
    bool isDataLoading;
    Error? loadingError;
    bool hasLoadingFailed;
    bool hasLoadingBeenRetried;
    num amountOfLoadingRetries;
    DateTime loadingRequestedAt;

    // Data.
    List<ChatRoom> chatRooms;
}
Enter fullscreen mode Exit fullscreen mode

Overall I think it makes sense to place metadata as close to an actual data as possible. Though nobody is preventing you from extracting metadata into a separate state if that makes sense for your application.

The example above could be further improved by moving metadata into a nested property. Like so:

class ChatRoomsState implements LoadableState {
    List<ChatRoom> data;
    ChatRoomsStateMetaData metaData;
}

class ChatRoomsStateMetaData {
    bool isDataLoading;
    Error? loadingError;
    bool hasLoadingFailed;
    bool hasLoadingBeenRetried;
    num amountOfLoadingRetries;
    DateTime loadingRequestedAt;
}
Enter fullscreen mode Exit fullscreen mode

However I must admit that advantage of that example is subjective. Use it if you wish.

State methods must set or return data. No side effects of any kind

As we discussed earlier doing any side effects inside of state methods is a bad idea.

When it comes to classes and restrictions it is actually not uncommon to set healthy boundaries. For instance, it is very common to use Repository pattern to deal with remote data. Here is an example of a repository:

class ChatRoomsGetterRepository {
    List<ChatRooms> getChatRooms() {
        final data = http.get('/api/chat-rooms');
        return data;
    }
}
Enter fullscreen mode Exit fullscreen mode

This class does only a single thing: it downloads a remote data. It does not do anything else. It doesn’t record analytics data, it does not modify UI. It is basically an abstraction, a contract. We do not know how it downloads the data or where it gets it from. We only know that if we use this class we will receive a list of chat rooms.

This repository is useful because it encapsulates logic and allow us to replace data source without too much hassle.

Let’s say we decided to migrate from REST to GraphQL. We can either rewrite the logic of ChatRoomsGetterRepository or we can create a ChatRoomsGraphqlGetterRepository with the same interface and simply replace the class. In the end of the day changing the source of chat rooms will not break anything else in our app.

This pattern is commonly used and its usefulness is undeniable. In my opinion good states should be treated the same as Repositories. These are classes that do very little: they hold and modify a small amount of data. We don’t know how exactly they are doing it and we don’t care. We only know that they can not do anything else and when we call state methods a certain piece of data is going to be updated. They are simple and obvious.

Let’s consider an example:

// BAD STATE. Don't do this.
ViewerState {

    User viewer?

    login() {}

    logout() {}

}
Enter fullscreen mode Exit fullscreen mode

What is wrong with this state you may ask? Well, for instance “logout” assumes that a lot of actions are going to happen when this method is invoked: a network request will be made, cache will be cleared, local storage will be erased, analytics event will be recorded, most of UI will be updated.

On the other hand, this snippet:

// GOOD.
ViewerState {

    User viewer?

    setViewer() {}

    unsetViewer() {}

}
Enter fullscreen mode Exit fullscreen mode

does not have such assumptions. When you invoke “unsetViewer” it is obvious that only the state is going to change. The logged in user object is going to be removed from the state. That is it. There are no side effects. No complications.

This state is simple, obvious and predictable.

Another way to think about states is to treat them as a micro databases. Imagine a database that can only hold very limited amount of data (a primitive, an object or a list of objects). This database can only hold and modify its data. A database can not and should not make API requests, update other databases or force UI to display notifications.

The less logic there is in a state the better

Note: a quick reminder. “Logic” in most cases means “business rules”.

It is very tempting to put logic inside of your states. But by doing so you might end up with the same problems as with side effects: by trying to reuse code in an improper place you will eventually shoot yourself in the foot. Too much logic in states make them unobvious and unpredictable.

Let’s say we need to display to the user a number of participants of a chatroom. Please consider this code snippet:

class ChatRoomState {
  int getNumberOfParticipants()
}
Enter fullscreen mode Exit fullscreen mode

Let’s say the number is 101. But inside the settings page of this chat room we also must display the number of participants. Though this number must be lower if currently logged in user is the creator of this chat room. Therefore in main chat room page the number of participants would be 101, but in settings page the number would be 100. Such is the business requirement.

This means that even though we are using the same data in two places of the application that data must be slightly different in one of them. We must add a special check somewhere to change the number of participants. The question is: where would you put it?

You might be tempted to change the state and add a special method:

class ChatRoomState {
  int getNumberOfParticipants()
    int getNumberOfParticipantsWithoutAdmins()
}
Enter fullscreen mode Exit fullscreen mode

Or you might want to add a flag to an existing method:

class ChatRoomState {
  int getNumberOfParticipants(bool isNumberOfAdminsRemoved)
}
Enter fullscreen mode Exit fullscreen mode

Sadly both of this solutions would be wrong because changing the state to fit special use case opens up a door to unobvious and unmaintainable states. For example, let’s say after a while we receive new business requirements and now we need to display 0 participants for chatrooms that are marked as “private”? In that case we will have to somehow check if logged in user is a member of the chatroom and if he should have access to this information. Since previously we were putting the logic regarding chat room participants inside the state we will have to continue doing so. With time the state will become more and more bloated and less and less predictable.

Just to clarify: this rule does not mean that adding new methods to states is completely prohibited.

Let’s we have a state that is responsible for the list of downloaded chat rooms:

class ChatRoomsState {
  List<ChatRoom> chatRooms;

  update(updatedChatRoomsList){}
}
Enter fullscreen mode Exit fullscreen mode

In many parts of our application we push new chat rooms to this list: after we created a chat room, after we were invited to the chat room or for other reasons. In that case it is quite handy to add a special method for it:

class ChatRoomsState {
  List<ChatRoom> chatRooms;

  update(updatedChatRoomsList){}

  addChatRoom(newChatRoom) {
    this.chatRooms.push(newChatRoom);
  }
}
Enter fullscreen mode Exit fullscreen mode

You might be wondering: what is the difference between first example with numbers of participants of a chat room where adding methods is bad and current example? Well, it is simple: the first example adds business rules to the state, the second one simplifies the way we interact with the state. The second one is a shortcut for this:

// Before:
newChatRoomsList = chatRoomsState.chatRooms.push(chatRoom);
chatRoomsState.update(newChatRoomsList);

// After:
chatRoomsState.addChatRoom(chatRoom);
Enter fullscreen mode Exit fullscreen mode

Even though the first example and the second one technically both add “logic” to states the second one simplifies our life and the first one eventually will make our life harder.

In conclusion: whenever you are unsure what to do remember this rule - the less logic there is in a state the better. Ideally there should be a single “update()” method and nothing else. Though if it makes sense for your use case do add new methods.

More complicated example

Simple examples are good to understand the basics of the idea. But that is often times missing is something more complicated to make sure you can apply the idea in practice. Because reality is always far more complicated and always forces you to reinvent the wheel.

Let’s say you were hired to work on a twitch.tv like website. On this site you can watch people stream games, you can participate in the chat, subscribe to the streamer and so on. Your first task is to add an ability to donate money to a streamer you are watching. How would you go about doing that task?

Task requirements:

  • Numbers of dollars in wallet must decrease.
  • Number of experience points must increase. (Context: users of our app can level up their profiles based on certain actions)
  • Chat must display “X donated Y$ to the streamer” announcement.
  • These changes to UI must happen BEFORE network requests are fired\completed (this technique is called “optimistic UI“).
  • “Success!” snackbar must be displayed if network requests were successful.
  • “Something went wrong.” snackbar must be displayed if network requests were unsuccessful.
  • All of the UI changes must be reverted if API responded with an error.
  • Donation success or failure must trigger an appropriate analytics event.

Before we jump into the implementation let's take a look at how developers usually approach the development. Let's read code that was written via states that contain logic:

// BAD. DON'T DO THIS.
class DonationsState { // <= First problem.
  donateToStreamer(amount) async {
    try {
      globalLoadingIndicatorState.setIsLoading(true);

      await walletState.subtractFunds(amount); // <= Second problem.
      await viewerState.addExperience(10); // <= Second problem.
      await activeChatState.anounceDonation(); // <= Second problem.

      await submitDonationRepository.submit(amount);
      analitycsTracker.trackDonationToStreamer(amount);

      snackBarService.displaySnackbar('Success!'); // <= Third problem.
    } catch (error) {
      walletState.addFunds(amount); // <= Fourth problem.
      viewerState.subtractExperience(10); // <= Fourth problem.

      activeChatState.removeDonationAnouncement();

      analitycsTracker.trackDonationToUserFailure(reason: error.message);
      snackBarService.displaySnackbar('Oops, something went wrong');
    }

    globalLoadingIndicatorState.setIsLoading(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

Descriptions of problems:

1) A state without data defeats its purpose. What is the point of subscribing to it if it doesn't have any data? And if you don't plan subscribing to it then it is not a state.

2) These state methods can internally call other state methods. When you read this code you cannot be sure that the app will do exactly the things you want it to do. The behavior is unpredictable.

3) Can you be sure that this service doesn't internally change states? Pretty much the the same problem as in previous point but with more steps.

4) Without reading the implementation of these methods can you be sure that they will do what you want of them? “walletState.addFunds” implies that we are giving money back to the user (we are increasing amount of numbers in UI in front of his eyes) because for whatever reason the network request failed. This means we only want to change the state (the state properties) without any side effects. We don't want any HTTP requests done, we don't want any extra snack bars to pop up. Are you sure that this method will do only that? And can you be sure that somebody won't change the implementation in the future? I highly doubt it.

As you can imagine this code will be hard to maintain and you always going to have a feeling that anything can break at any point in the future. You can't rely on it. It is unpredictable.

Let's take a look at the code where logic was extracted from the states. Here is how Predictable State Management looks in practice:

// GOOD. DO THIS.
class DonateToStreamerUseCase {
  donateToStreamer(amount) async {
    try {        
      globalLoadingIndicatorState.setIsLoading(true);
      activeChatState.anounceDonation();
      walletState.subtractFunds(amount);
      viewerState.addExperience(10);

      await subtractFundsRepository.subtract(amount);
      await addExperienceRepository.add(amount);
      await submitDonationRepository.submit(amount);

      analitycsTracker.trackDonationToStreamer(amount);

      snackBarService.displaySnackbar('Donation successful!');
    } catch (error) {
      walletState.addFunds(amount);
      viewerState.subtractExperience(10);

      activeChatState.removeDonationAnouncement();

      analitycsTracker.trackDonationToUserFailure(reason: error.message);
      snackBarService.displaySnackbar('Oops, something went wrong');
    }

    globalLoadingIndicatorState.setIsLoading(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see we did two things here: 1) we turned the class from a state into a use case and 2) we extracted all repositories from states. By doing so we gain predictability and control over our code. And the ability to have a total control over what happens in your app is the main point of this article. With this new way of writing code you will be able to introduce any kind of changes. You want to make sure that user has enough funds by making a special request to the API? Not a problem. You want to remove global loading indicator and instead use “isLoading” property of walletState? Sure, not a problem. You want display error by using activeChatState instead of a snackbar? You can do so by rewriting 4 lines of code instead of rewriting 4 methods.

Even though predictability of is amazing the only downside of this approach is it is harder to reuse code now. Let’s say that each time we award user with experience we also want to check if the user has enough points to level up. If he does we want to display some kind of leveling up animation, track analytics event and make an API request.

In that case we are faced with a dilemma. Do we place all this leveling code into a service and lose predictability and control. Think of “Optimistic UI” requirement of our task, with a separate service it will be hard to implement it.

How do we reuse code and keep predictability? That is a topic of our next conversation.
TO BE CONTINUED.

Predictable vs unpredictable (properties comparison)

Unpredictable states:

  • Contain multiple data points
  • Have methods with side effects
  • Can have unobvious method names
  • Have no rules on how to use states
  • Contain business rules of application
  • Do not follow SOLID principles. Mainly S and O

Predictable states:

  • Contain only a single point of data
  • Have methods that only changes data of the state. No side effects of any kind
  • Have obvious method names
  • Have specific rules
  • Act as containers for data. They do not contain business rules
  • Follow SOLID principles. Predictable states have only a single reason to change and new functionality is added by adding new states instead of extending existing ones

Additional Benefits of PSM

Easier maintenance and onboarding of new developers

When logic is extracted out of states it is usually placed inside Controllers or Use Cases. Compared to the previous approach where logic was mixed between view layer, states and controllers this allows developers to easier understand business rules of the application.

This also makes onboarding of new developers easier and reduces the “bus factor”.

Undo\redo functionality

When your states does not contain any logic or side effects it is extremely easy roll back state changes.

class UndoableCounterState {
  counter = 0;
  _stateHistory = [];

  update(newCounterValue) {
    _stateHistory.add(counter);
    counter = newCounterValue;
  }

  undo() {
    counter = _stateHistory.last;
    _stateHistory.deleteLast();
  }
}
Enter fullscreen mode Exit fullscreen mode

Persistence

In the same manner persistence (saving and restoring state via local storage) can be implemented:

class StateThatCanBeSaved {
    // Hint: if you have an object instead of an integer you can persist it by
    // parsing it into JSON and then saving JSON to local storage as a string.
  counter = 0;

  update(newCounterValue) {
    counter = newCounterValue;
    localStorage.set('counter', newCounterValue); 
  }

  restoreState() {
    counter = localStorage.get('counter');
  }
}
Enter fullscreen mode Exit fullscreen mode

Why do people get away with unpredictable states?

Short answer:

Developers don’t actually get away with doing states wrong. The reason why it feels that way is because it takes time for problems to pop up. And by the time they encounter bugs and difficulties they are so deep down the rabbit hole that it is impossible to see solutions to fundamental problems. Quick fixes and workarounds do help in the beginning but eventually make states unmaintainable in the long run.

Long long answer:

Since I claimed that most of the teams and companies do a lot of fundamental mistakes when it comes to working with state management how come nobody have noticed any problems?

As with anything there are a lot of ways to do something wrong and only a limited amount of ways to do something right.

1) They don’t.

Usually it takes a lot of time to get in a situation when adding new features or fixing a bug gets frustratingly difficult. By that time it is too hard to tell where exactly everything went wrong. And even if developers encounter such problems sooner they simply update the states code and move on. Mind you: adding more methods to states in order to add a feature breaks SOLID principles.

2) People don’t write anything complicated

Let’s face it: lion’s share of applications don’t encounter any problems because their lack of complicated logic allows them to. I am not saying that most of the applications were easy to built. But I am saying that most of them do not deal with interconnected states, multi teams environments and long lists of business requirements for every user action. Usually it is as simple as: download an object, use the object to display something to the user, update the object in the state when an action occurred.

Their approach quickly falls apart when logic gets complicated. For instance, when an a single action leads to a change of 5 different states and to a call to 20 different services.

3) People work in small teams and\or on small projects.

When developer encounters a problem (and all of them inevitably do as project gets larger) it is easy to rewrite big chunks of the application if 3 people are working on it. However when there are 20 teams and 100 developers working on a single app then changing existing code quickly gets complicated.

4) People don’t care about doing things properly. They are blind to problems.
When we encounter problems during development we usually try to add a quick fix on top of the problem or we try to do a workaround instead. We add bad code on top of bad code instead of rewriting the bad parts. Basically we are more focused on finishing a task and gaining short term benefits. This benefits always hurt us in the long run.
As it was said in “Clean Code” by Martin Robert (and I am heavily paraphrasing here): “We don’t have time to write clean code so we write bad code that will slow us down even more in the future”.

Top comments (0)