It all starts with the idea that something is not right. You are picking up a new feature or expanding on an existing feature and it just does not fit nicely in the existing codebase. Or you are copying some logic for the sixth time. The first few times this is happening, you just put your hand before your eyes en force that logic in there (with the other hand). That is however not a long-term solution, and where refactoring comes in.
Refactoring could mean many things. From low impact to high impact we have the following:
Reorganising: moving some logic around or renaming things. Maybe creating a reusable function or even creating a new module to give your functions a better place to stay in.
Recreating abstractions: the existing abstractions don't hold up any more, you are hacking your way around the existing functions. Time to refigure out the abstractions you need, create them and remove the old ones.
rm -rf ./*the old and start over. Like swapping out a form library. Or when the above two methods of refactoring are not used appropriately.
When to execute refactors is a subjective thing. There are a few things to keep in mind. The most important ones:
Reorganisation and recreating abstractions are ways of refactoring that are part of normal development. This can be picked up while working on the ticket that is impacted. When discussing with a peer how a feature or fix should be implemented, also think about the impact on the codebase. And figure out together if you need to reorganise or recreate abstractions. Keep in mind that you can't do all the refactoring you want, at the end of the day some feature or fix needs to be finished.
Rewrites are often big, but also hard to sell. Discuss rewrites with the team and PM to schedule these optimally. Most of the time you want to execute rewrites in already big iteration. Make it clear to the team and PM why this rewrite is necessary and have a good idea about impact and size of this refactor.
How to execute refactors is somewhat hard to put on paper. Always start small and build confidence. Maybe rename a variable to make the intent clearer. Now try to use your IDE to help you with this. Next up try to extract some logic that is copied over six times in to a function, your IDE can probably do that too. If you are lucky, your IDE can spot repeatable use of that same piece of code and replace all its usages with the new function you created. For these kind of things, trust your tools like the IDE, linter, types and tests to help you. They will catch errors you made a long the way. And if it doesn't work out, you always have Git to revert to a working state.
In the end, it is hard to predict what the future holds. Create abstractions to use now instead of possible ones to use in the future. Always try to make intent clear and align your codebase with the business logic it contains instead of working around it. Making it easier for future you to work on the project!