projects

Reference* library

Website of a non-existent library that curates a cherry-picked list of books for developers.

banner

Full-stack app of a library that curates a cherry-picked list of books for developers.

 

Palette & Basic elements

It turns out your interfaces can look nice with Material UI if you apply some fine-tuning to it.

Before trying it out during Integrify projects, I've always thought it made your stuff look like bootleg Google websites.
But it provides you with solid foundation (grid, color palette, form elements) to build something neat on top of it.

For example, I used a lot of card elements in this project:

cards

All of those are standard <Box /> components, since I find <Card /> too opinionated for my needs.
But the color palette of those utilizes the main UI function I have on this project:

// utils/composeBackgroundColor.ts

export default function composeBackgroundColor(
  theme: Theme,
  level: number = 0
) {
  const step = level * 100;
  return theme.palette.mode === 'dark'
    ? theme.palette.grey[
        (900 - step).toString() as keyof typeof theme.palette.grey
      ]
    : theme.palette.grey[
        (100 + step).toString() as keyof typeof theme.palette.grey
      ];
}

So, Material UI allows me to get up & running with basic abstractions quickly and concentrate on custom styling.
Another good thing is that I don't have to learn some class-name system in order to start using it – they expose all the stuff with plain React components.
Noice!

Feedback

I believe one of the most important aspects of making an interface look alive is making it responsive to user's actions.
As some HTTP-client libraries suggest, we can divide user's actions into two categories:

  1. Querying – getting some data from the data source;
  2. Mutating – changing some data on the data source.

Queries

When it comes to querying, RTK-Query provides useful handlebars to implement feedback:

// from rtk-query docs
const { data = [], isLoading, isFetching, isError } = useGetPostsQuery();

With these, we can react to different states of the interface accordingly.
My favorite thing to do here when I handle lists is to implement Finite State to check on loading, error, list and empty states:

At first, I define all possible states with plain strings:

const AUTHORS_LOADING = 'LOADING';
const AUTHORS_ERROR = 'ERROR';
const AUTHORS_LIST = 'LIST';
const AUTHORS_EMPTY = 'EMPTY';

type AuthorsState =
  | typeof AUTHORS_LOADING
  | typeof AUTHORS_ERROR
  | typeof AUTHORS_LIST
  | typeof AUTHORS_EMPTY;
  
// Later, in the component code itself:
const [state, setState] = useState<AuthorsState>(AUTHORS_LOADING);

Then, I untroduce a useEffect that connects to the handles from RTK-Query and updates the state accordingly:

useEffect(() => {
  if (isFetching) {
    setState(AUTHORS_LOADING);
    return;
  }

  if (isError) {
    setState(AUTHORS_ERROR);
    return;
  }

  if (authorsResponse?.data.authors.length === 0) {
    setState(AUTHORS_EMPTY);
    return;
  }

  setState(AUTHORS_LIST);
}, [isFetching, isError, authorsResponse?.data.authors.length]);

 
All this setup allows me to render the component in an understandable, readable manner with simple flags:

return (
  {state === AUTHORS_LIST && renderList(authorsResponse?.data)}
  {state === AUTHORS_LOADING && renderSkeletons()}
  {state === AUTHORS_EMPTY && (
    <ListEmpty
      title="No authors found"
      description="Please try changing the filters."
    />
  )}
  {state === AUTHORS_ERROR && (
    <Grid item xs={12}>
      <DisplayError
        title="Uh oh, failed to fetch authors"
        errorOutput={error}
      />
    </Grid>
  )}
)

Mutations

Reacting to mutations is different from queries. First of all, mutations are most likely to happen in forms, whereas queries can be anywhere.

Also, mutations are actions, and queries are mostly just data getters.
Btw, there's an existing term for it, it's called CQRS (Command and Query Responsibility Segregation) principle.
By this principle, a function can be either a Command or a Query, but can never be both at the same time. In terms of this principle, a mutation is always a command.

Reacting to commands requires notifying a user about the result of their action and whether it was successfull or not. RTK-Query returns isError and isSuccess flags from useMutation hooks, but I think it makes more sense to use actions when reacting to actions.

Here's the wrapper I use over all of my mutations on this project:

// utils/handleAsyncOperation.ts

export default async function handleAsyncOperation(
  submitFn: () => Promise<any>,
  {
    onSuccess,
    onError,
    fallbackErrorMsg = 'Unknown error happened.',
    expectEmptyResponse = false,
  }: InputHelpersType
) {
  try {
    const result = await submitFn();
    if (
      !expectEmptyResponse &&
      _.get(result, ['data', 'status'], 'error') === 'error'
    ) {
      const error = _.get(
        result,
        ['error', 'data', 'message'],
        _.get(result, 'message', fallbackErrorMsg)
      );
      onError(error);
      return;
    }
    onSuccess(result);
  } catch (error: any) {
    const errorMsg = _.get(error, 'message', fallbackErrorMsg);
    onError(errorMsg);
  }
}

The sole purpose of it is to set me up with onError and onSuccess handles.

await handleAsyncOperation(() => onSubmit(valuesToSubmit), {
      onSuccess: () => {
        setFormState('SUCCESS');
        setMessage(successMessage);
        resetOnSuccess && resetForm();
      },
      onError: (error) => {
        setFormState('ERROR');
        setMessage(error);
      },
    });

This thing allows me to conveniently display toasts and form status bars: