In programming, an abstraction is a way of moving common or repetitive code into a single, centralized place where it can be used by other pieces of code. It's actually not that hard to recognize when some level of abstraction is necessary because you'll find yourself writing code that you've written before. If you write the same thing - or very similar things - two or three times, it may be time to abstract that similar code into a "higher order" function or component or unit of some kind. "Higher order" refers to code that is invoked to construct other code. In the case of higher order functions, it means a function that accepts or returns a function. For example, maybe you have a function that returns a property on an object, like:

function getTax(payment) {  
  return payment.tax;
}

Nothing wrong with that code. But you may find that you need other properties from the same object, like subtotal for example. That's where a higher order function can help. We could write a function that takes the property to look up and returns the above function (with slight modifications).

const fromPayment = (prop) => (payment) => payment[ prop ];  

If you're not familiar with ES6 features, this could also be written like this:

function fromPayment(prop) {  
  return function(payment) {
    return payment[ prop ];
  };
}

So then you could use (either version of) this to create accessor functions more simply. Like this:

const getTax = fromPayment('tax');  
const getSubtotal = fromPayment('subtotal');  

Higher order functions/components are not the only form of abstraction, but it's a relatively common and simple pattern and makes for good examples.

Again, recognizing that you need the abstraction is the easy part. Deciding how to implement the abstraction takes more work. This is what I found myself doing earlier this week. I had a piece of code that needed abstraction, specifically a react component that rendered an error message. There were two components that needed to (potentially) render an error component, and the logic for displaying that component was very similar.

My first abstraction was to make the error component sort of a standalone thing that could toggle its own visibility. The idea was, you could pass the error type of interest in and the component could subscribe to the redux store and show something when/if that error happened. Something like:

// Note, some irrelevant details are omitted here
import React from 'react';  
import { useSelector } from 'react-redux';

const ErrorMessage = ({ errorType }) => {  
  const error = useSelector((state) => state.errors[ errorType ]);

  return (
    <>
      {error && <div>Something bad happened</div>}
    </>
  );
};

export default ErrorMessage;  

And then you'd use it like this:

<ErrorMessage errorType="postError" />  

So it's pretty easy to render, as the messy conditional is inside the error component and you don't have to worry about it each time. Once I had to add a second kind of error, however, it became clear to me pretty quickly that this was the wrong abstraction. Do you add multiple ErrorMessage components (one for each type), even though they show the same message?

<ErrorMessage errorType="postError" />  
<ErrorMessage errorType="postEditError" />  

Or maybe you make errorType accept multiple errorTypes?

<ErrorMessage errorType={['postError', 'postEditError']} />  

Neither of those feel great. The original problem I was trying to solve was the feeling that there was a lot of "hook-based setup code" at the top of the outer component and moving the error selector into the error component reduced some of that. But it was clear to me at this point that the thing I really wanted to write was

{(postError || postEditError) && <ErrorMessage />}

So I looked for a different abstraction. I noticed that I was using the success and pending states for the same async actions, so I had code that looked like

const pending = useSelector((state) => state.pending.postPending);  
const success = useSelector((state) => state.success.postSuccess);  
const error = useSelector((state) => state.errors.postError);  

But these states are all connected to the same action (posting something), and the specific state name can be inferred based on that action, so it's reasonable to abstract that into a single hook that aggregates these states. So I wrote this hook to do that.

import { useSelector, useDispatch } from 'react-redux';  
import * as actions from '../actions';

export function useActionStatus(type) {  
  const dispatch = useDispatch();
  const pending = useSelector((state) => state.pending[`${ type }Pending`]);
  const success = useSelector((state) => state.success[`${ type }Success`]);
  const error = useSelector((state) => state.errors[`${ type }Error`]);
  const clearError = () => dispatch(actions[`${ type }Error`]());

  return [success, pending, error, clearError];
}

This function abstracts the messy state selection into one place (and adds a useful clearError function), so that my outer component could now do something like this.

const [postSuccess, postPending, postError, clearPostError] = useActionStatus('post');  
const [postEditSuccess, postEditPending, postEditError, clearPostEditError] = useActionStatus('postEdit');

return (  
  <div>
    <div>Other stuff</div>
    {(postError || postEditError) && <ErrorComponent />}
  </div>
);

This abstraction is much clearer and easier to use (and actually reduces the amount of setup code in the initial component more than the original abstraction did). But here's the thing; you don't always find that right abstraction. I've written a lot of wrong, overcomplicated, or suboptimal abstractions. Some I've caught and corrected immediately. Otherwise, I've refactored much later. Some I probably haven't noticed yet. That's why choosing the right abstraction is so hard. You don't always know until much later that it wasn't the right abstraction.

Only practice and experience can help you. You start to recognize patterns that are useful and that are likely to work as abstractions. You also start to get a feel for when you're abstracting too much. Some code, even though it's repetitive, shouldn't be abstracted because the resultant code is unwieldy and inflexible. But even in those cases, I think the exercise of abstracting it is helpful because it confirms that it wasn't the right approach and maybe reveals another approach that will work. Like everything in programming, it's an iterative process that takes time to perfect.

Happy coding!