Media Queries in JS/TS done right
Tags: webdev, css, javascript, typescript
Published: 2022-12-05 18:01:45

Recently, I had a situation that involved listening to the screen width of an application.
There are a lot of methods of achieving this, but I wanted something simple, reusable & generic that did not impact performance too much.

I thought that listening to `resize` event on `window` and checking the `innerWidth` is way too slow especially since everything related to layout forces a [reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a).

I discovered quickly while searching for solutions, the [window.matchMedia()](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) API and it seemed like that was the best solution (it is also supported in all of the major browsers). 

The only thing left to do, was to implement a simple, reusable & generic solution that uses this underneath. Here's a helpful [MDN link](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Testing_media_queries) that got me started.

What I wanted was to be able to `subscribe` to different media queries from different components across the application, without creating new listeners.

The solution I came up with, was to create a `static class` (well, a `class` with `static` methods and variables), that would keep a list of queries & subscriptions for those queries (and a list of listener refs so it can be easily cleaned up).

```typescript
export interface IQueryList {
    [key: string]: {
        query: MediaQueryList;
        subscribers: Array<(matchesQuery: boolean) => void>;
    };
}

interface IEventListener {
    [key: string]: (event: MediaQueryListEvent) => void;
}

export class MediaQueryListener {
    private static _subscribers: IQueryList = {};
    private static _eventListenersRefs: IEventListener = {};

    static subscribe() {
        //...
    }
    static unsubscribe() {
        //...
    }
}
```

This class would expose 2 public methods: `subscribe` and `unsubscribe`.

The `subscribe` method takes a callback function that receives a `boolean` parameter representing if the query matches or not, and the actual query in a string format (eg: `'(max-width: 768px)'`).
It then checks if there is already a subscription made for this query from some other place: 
- if it is, it only adds the callback to the list of subscribers for that query.
- if it's not, it adds the query to the list, with the only subscriber being the passed callback, then it starts to listen to changes for the newly created query.

Eitherway, it also calls the callback instantly with the current query match.

```typescript
static subscribe = (cb: (matchesQuery: boolean) => void, query: string) => {
    if (!this._subscribers[query]) {
        this._subscribers[query] = {
            query: window.matchMedia(query),
            subscribers: [ cb ],
        };

        // keep the refs so we can remove the listeners in unsubscribe
        this._eventListenersRefs[query] = event => {
            this._subscribers[query]
                .subscribers
                .forEach(subscriber => subscriber(event.matches));
        };

        // create the listener only on first subscription for the query
        this._subscribers[query]
            .query
            .addEventListener('change', this._eventListenersRefs[query]);
    } else {
        this._subscribers[query].subscribers.push(cb);
    }

    // Call the callback with the current value of the media query
    cb(window.matchMedia(query).matches);
};
```

The `unsubscribe` method receives the same callback function & query in a string format, and removes the callback from the subscribers of that query. It also checks if there are no more subscribers left, and removes the media query listener so there are no hanging listeners left.

```typescript
static unsubscribe = (cb: (matchesQuery: boolean) => void, query: string) => {
    if (!this._subscribers[query]) {
        return;
    }

    this._subscribers[query].subscribers = this._subscribers[query].subscribers.filter(subscriber => subscriber !== cb);

    if (this._subscribers[query].subscribers.length === 0) {
        this._subscribers[query].query.removeEventListener('change', this._eventListenersRefs[query]);

        delete this._subscribers[query];
        delete this._eventListenersRefs[query];
    }
};
```

As I said, it's a basic wrapper on top of `matchMedia` api. There are probably multiple packages out there that achieve the same thing in a way or another, I did not research specifically for those packages in advance before creating my solution since I liked the idea of writing one.

Thanks for reading the article !