fresnel icon indicating copy to clipboard operation
fresnel copied to clipboard

Expose a `useMedia` hook

Open smithki opened this issue 4 years ago • 11 comments

I've found myself loving Fresnel for responsiveness, but I still miss the flexibility of window.matchMedia to access breakpoints using a React Hooks pattern. However, I think there's a way to map this functionality to Fresnel's approach!

Let's say @artsy/fresnel exposes a useMedia hook from the result of createMedia, like so:

import { createMedia } from '@artsy/fresnel';

const { Media, MediaContextProvider, useMedia } = createMedia({ ... });

Then, from any functional component that's nested within <Media>, I can use the hook like so:

function App() {
  <>
    <Media at="sm">
      <SomeView />
    </Media>

    <Media greaterThan="sm">
      <SomeView />
    </Media>
  </>
}

function SomeView() {
  const atSm = useMedia({ at: 'sm' });
  const greaterThanSm = useMedia({ greaterThan: 'sm' });

  if (atSm) return <>Something for small screens</>
  if (greaterThan) return <>Something for bigger screens</>
}

If useMedia is invoked without the <Media> context provider, then the return value from useMedia should always be false and maybe a warning is raised.

smithki avatar Jan 08 '21 19:01 smithki

One thing that this would impact is SSR rendering, as outlined in the "Why not conditionally render" section of the docs. That said, I think this hook could have some value.

@alloy @zephraph, y'all have any thoughts?

damassi avatar Jan 08 '21 19:01 damassi

One thing that this would impact is SSR rendering

If useMedia is relying only on the props of a <Media> component higher in the tree (as opposed to an actual window.matchMedia check), would this still pose a problem?

smithki avatar Jan 08 '21 19:01 smithki

@smithki it would be a problem, yeah. The challenge here is during SSR we don't know which breakpoints we're rendering for so we render all the breakpoints and use media queries to control which is displayed or not. Essentially, the best we could do here on the server is make everything true to render all of those components or make everything false to render none of them.

That's why we have the wrapping <Media> components, because conditional rendering of any sort fundamentally breaks down when server side rendering.

zephraph avatar Jan 14 '21 21:01 zephraph

The challenge here is during SSR we don't know which breakpoints we're rendering for so we render all the breakpoints and use media queries to control which is displayed or not.

I think for the use-case I described it shouldn't be a problem that useMedia will always return true in the server, because those components are being rendered anyways as they are wrapped with <Media> somewhere higher. What I'm proposing is not necessarily a "real" media query hook, but rather an interface for functional components deeper in the tree to know which props exist on the nearest <Media> parent.

This also means that useMedia would only work on components that are wrapped with <Media>.

I think this could be an ergonomic way of solving the problem hi-lighted in the README, where instead of a HoC pattern, we use hooks!

smithki avatar Jan 14 '21 22:01 smithki

So like, the question is how to programmatically represent the underlying architecture of this node structure 🤔:

<Media at="sm">
  ...
</Media>

It seems like all the bits are there to do this, but would be good if the original author (@alloy) could chime in on it.

damassi avatar Jan 14 '21 22:01 damassi

I've used the logic of the <Media> component to populate Redux with the current breakpoint. It's relatively simple albeit somewhat contrived, as it only populates the breakpoint in Redux after the browser has loaded. It's not perfect, but it works for my use cases. Try rendering this near the root of your application's tree.

import React, {useLayoutEffect} from "react"
import {useDispatch} from "react-redux"
import {Media} from "lib/utils/media/Media"
import {SET_CURRENT_BREAKPOINT} from "redux/reducers/ui/ui-reducer"

const mediaConfig = ["xxs", "xs", "sm", "md", "lg"]

/**
 * This is a dummy component that renders an empty fragment.  Its only purpose is to set
 * the current breakpoint, and is rendered conditionally based on the parent <Media> context.
 */
function BreakpointOption({breakpoint, dispatch}) {
  useLayoutEffect(() => {
    dispatch({type: SET_CURRENT_BREAKPOINT, value: breakpoint})
  }, [breakpoint, dispatch])
  return null
}

/**
 * A lightning quick solution to set the current breakpoint on browser hydration.
 * <Media> renders on the server and when it's time for the browser to hydrate,
 * it computes the layout very quickly.
 */
function BreakpointSwitchComponent() {
  const dispatch = useDispatch()
  return (
    <>
      {mediaConfig.map((at, bp) => {
        return (
          <Media key={at} at={at}>
            <BreakpointOption breakpoint={bp} dispatch={dispatch} />
          </Media>
        )
      })}
      <Media greaterThanOrEqual="xl">
        <BreakpointOption breakpoint={5} dispatch={dispatch} />
      </Media>
    </>
  )
}

export default BreakpointSwitchComponent

R-Bower avatar Jan 18 '21 05:01 R-Bower

It seems like all the bits are there to do this, but would be good if the original author (@alloy) could chime in on it.

I agree that functionally it seems like the bits are there, perhaps an initial naive PR would better show if there are issues being overlooked, but the thing that worries me is making it too easy to shoot yourself in the foot by making it easier to do if … else … type of rendering.

Your example does the right thing, but it doesn’t seem far-fetched to imagine that somebody naively would write the same example like so:

function SomeView() {
  const atSm = useMedia({ at: 'sm' });
  const greaterThanSm = useMedia({ greaterThan: 'sm' });

  if (atSm) {
    return <>Something for small screens</>
  } else if (greaterThan) {
    return <>Something for bigger screens</>
  }
}

Or:

function SomeView() {
  const atSm = useMedia({ at: 'sm' });

  if (atSm) {
    return <>Something for small screens</>
  } else {
    return <>Something for bigger screens</>
  }
}

As of yet I’m on the fence, but open to being shown ways to combat my worries and opinions of @damassi & @zephraph.

alloy avatar Jan 19 '21 11:01 alloy

I’m trying to think this all through and wondering if the if … else … pattern matters if the hook is required to be used inside a Media component, rather than MediaContextProvider. Perhaps this is where our shared understanding is lacking?

alloy avatar Jan 19 '21 11:01 alloy

what if atSm were used within <MediaContextProvider>, but you run multiple passes of rendering (ReactDOMServer.renderToString), alternating atSm so it is both true (first pass) and then false (second pass), and then join the rendered layouts together afterwards (so all layouts are still shipped to the client)? I imagine you could then do:

function App() {
  <MediaContextProvider>
      <SomeView />
  </MediaContextProvider>
}

function SomeView() {
  const atSm = useMedia({ at: 'sm' });

  if (atSm) {
    return <>Something for small screens</>
  } else {
    return <>Something for bigger screens</>
  }
}

Which would output all the breakpoints:

<>Something for small screens</>
<>Something for bigger screens</>

So the interesting case from the Readme would become:

function SomeView() {
  const atSm = useMedia({ at: 'sm' });

  return <Sans size={atSm ? 2 : 3}>
}

Returning:

<Sans size={2}>
<Sans size={3}>

It's late, so I haven't thought hard about how the multiple passes of rendering approach would work when combining several useMedia hooks and advanced branching logic. But in principle it should work: setting a breakpoint and going through all the motions. Then setting the next breakpoint, and so going through all the motions again. I feared it could become a combinatorial explosion of breakpoints, necessitating an exponential times of rendering (2^X). But I don't think we need all combinations, because we just need the output of a single breakpoint at a time, so all the other ones could be set to false.

redbar0n avatar Jun 07 '21 20:06 redbar0n

This way, you could avoid <Media> components altogether, and the extra DOM nodes that they inject. Which were some times problematic. By using @xiata's code to apply attributes to all the elements instead.

The final output could be:

<div data-breakpoint="sm">Something for small screens</div>
<div data-breakpoint="lg">Something for bigger screens</div>

Which Fresnel could target using a CSS attribute selector like this:

[data-breakpoint="sm"] {
  display:none !important;
}

This would solve this issue: #152 - Optional container-less rendering by using React.Fragments And this issue as well: #183 - Compatibility with React Native Web + NextJS

I imagine it could also be a win for Fresnel for simple SSR'ed pages that just need a little bit of classic responsivity (media queries for styles) without any change to the markup/structure.

redbar0n avatar Jun 07 '21 20:06 redbar0n

Some potential inspiration: useMediaQuery

This is a CSS media query hook for React. It listens for matches to a CSS media query. It allows the rendering of components based on whether the query matches or not.

redbar0n avatar Jun 10 '21 15:06 redbar0n

Been a minute, going through issues and clearing out things that are stale. Happy to check out a PR if anyone has it.

damassi avatar Nov 03 '23 03:11 damassi