Most programmers know not to keep global or shared state, and when you do, do it so less as possible. But why shouldn't we ? Why is it so bad to use shared state and how does it relate to other constructs such as dependency injection and pure functions.
Programmers often forget that we tends to read more code than we write. But what they forget more often, is how changes affect the mental effort required to understand the code. I refer to this as mental complexity. This mental complexity tend to increase with every addition, it increases tenfold when adding features or adding constraints on some shared context within your application. Even if problems with consistency and concurrence are ignored, shared state is still seen as a bad practice. And I share this view, but not because it is a common source of bugs. Using shared state does not introduce a bug, the bugs are a symptom of something else. The underlying issue all comes down to the strain it puts on your mental model.
While programming you often build up a mental model of everything you need to know and use. For smaller programs everything fits perfectly in your brain and using shared state will not cause much problems (yet). But with every addition this mental model increases, causing all kinds of problems. Developers will start to forget that some things existed, forget to change some shared variable or forget that "no at this stage in the program, you cannot use that variable or it has been changed by something else". The size and the burden of this mental model is determined by multiple factors, on multiple different levels. Let's start by examining how we build this mental model when we try to understand an arbitrary piece of code.
When you try to understand a certain piece of code, you'd often look at specific units like a class, a method or a function. Each of these have their own 'context'. This context is a combination of scope, the state of everything in scope and how this state changes over time. With scope we mean every variable, class and function a certain unit of code uses. The scoping rules mostly depend on the rules of your programming language, but these are consistent throughout your code. In contrast, your state changes all the time. The current function can change local variables, variables outside its local scope or through a reference passed as an argument. All of this would influence the scope of the piece of the your are looking at. Thus also affecting the effort required mental to build this mental model.
Needless to say, the more things are in scope, the bigger the strain on the mental model is. On top of that, the more different calling states it has, the bigger the strain on the mental model. And lastly, the more the state changes throughout the function, the bigger the strain on the mental model. Lets look at a favoured programming concept as an example: Pure functions. Pure functions are functions which are side effect free and their output are a one to one mapping with there input. No state, no side-effects, fully predictable. There scope is reduced to a minimum, it is as big as there input arguments. What is more, they are side-effect free, meaning that they will not cause a state change anywhere else, which further limits the changes we need to track and thus the strain on our mental model. In contrast, global variables have a huge strain on your mental model, because potentially any piece of code in your program can use it and thus it increases the mental strain on every line of code.
But this affects every line of code you change, and with every library you add, most things you add, add to the mental strain. This mental model also ties in really neat with other common good practices! To start with dependency injection instead of a shared container, which reduces the scope of a piece of the. Using smaller interfaces instead of concrete implementation, which limits the scope of all clients of said interface. Keeping your functions smaller, which reduces the size of the context you need to keep in mind. Immutable objects instead of long lived mutable object, which eliminates the change you need to track throughout the objects live time. Thus all these good practices try to reduce the mental strain, which increases the maintainability of your code.
So instead of increasing complexity by carelessly creating new concepts throughout your code. Try to think about what your additions do in terms of mental strain. Try and write code with the smallest scope and context, while reducing side-effects and variable changes. Not because somebody says so, it is called a good practice or it is the cool thing to do. But because this reduces the processing power required to read the code and it will be easier for anybody, new and veteran alike, to join in. Keep an eye out for the mental strain your code produces.