Skip to content

Commit fc2a278

Browse files
authored
feat(hooks): add remix example (#404)
1 parent abf6a73 commit fc2a278

File tree

19 files changed

+500
-0
lines changed

19 files changed

+500
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
3+
"rules": {
4+
"@typescript-eslint/naming-convention": "off",
5+
"spaced-comment": ["error", "always", { "markers": ["/"] }]
6+
}
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node_modules
2+
3+
/.cache
4+
/build
5+
/public/build
6+
.env
7+
8+
/app/tailwind.css
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Welcome to Remix!
2+
3+
- [Remix Docs](https://remix.run/docs)
4+
5+
## Development
6+
7+
From your terminal:
8+
9+
```sh
10+
npm run dev
11+
```
12+
13+
This starts your app in development mode, rebuilding assets on file changes.
14+
15+
## Deployment
16+
17+
First, build your app for production:
18+
19+
```sh
20+
npm run build
21+
```
22+
23+
Then run the app in production mode:
24+
25+
```sh
26+
npm start
27+
```
28+
29+
Now you'll need to pick a host to deploy it to.
30+
31+
### DIY
32+
33+
If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
34+
35+
Make sure to deploy the output of `remix build`
36+
37+
- `build/`
38+
- `public/build/`
39+
40+
### Using a Template
41+
42+
When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.
43+
44+
```sh
45+
cd ..
46+
# create a new project, and pick a pre-configured host
47+
npx create-remix@latest
48+
cd my-new-remix-app
49+
# remove the new project's app (not the old one!)
50+
rm -rf app
51+
# copy your app over
52+
cp -R ../my-old-remix-app/app app
53+
```
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { RemixBrowser } from '@remix-run/react';
2+
import { hydrateRoot } from 'react-dom/client';
3+
4+
hydrateRoot(document, <RemixBrowser />);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { EntryContext } from '@remix-run/node';
2+
import { RemixServer } from '@remix-run/react';
3+
import { renderToString } from 'react-dom/server';
4+
5+
export default function handleRequest(
6+
request: Request,
7+
responseStatusCode: number,
8+
responseHeaders: Headers,
9+
remixContext: EntryContext
10+
) {
11+
const markup = renderToString(
12+
<RemixServer context={remixContext} url={request.url} />
13+
);
14+
15+
responseHeaders.set('Content-Type', 'text/html');
16+
17+
return new Response(`<!DOCTYPE html>${markup}`, {
18+
status: responseStatusCode,
19+
headers: responseHeaders,
20+
});
21+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { MetaFunction } from '@remix-run/node';
2+
import {
3+
Links,
4+
LiveReload,
5+
Meta,
6+
Outlet,
7+
Scripts,
8+
ScrollRestoration,
9+
} from '@remix-run/react';
10+
11+
export const meta: MetaFunction = () => ({
12+
charset: 'utf-8',
13+
title: 'React InstantSearch Hooks - Remix',
14+
viewport: 'width=device-width,initial-scale=1',
15+
});
16+
17+
export default function App() {
18+
return (
19+
<html lang="en">
20+
<head>
21+
<Meta />
22+
<Links />
23+
</head>
24+
<body>
25+
<Outlet />
26+
<ScrollRestoration />
27+
<Scripts />
28+
<LiveReload />
29+
</body>
30+
</html>
31+
);
32+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import algoliasearch from 'algoliasearch/lite';
2+
import type { InstantSearchServerState } from 'react-instantsearch-hooks-web';
3+
import {
4+
DynamicWidgets,
5+
Hits,
6+
InstantSearch,
7+
InstantSearchSSRProvider,
8+
Pagination,
9+
RefinementList,
10+
SearchBox,
11+
useInstantSearch,
12+
} from 'react-instantsearch-hooks-web';
13+
import { getServerState } from 'react-instantsearch-hooks-server';
14+
import { history } from 'instantsearch.js/cjs/lib/routers/index.js';
15+
import instantSearchStyles from 'instantsearch.css/themes/satellite-min.css';
16+
17+
import type { LinksFunction, LoaderFunction } from '@remix-run/node';
18+
import { json } from '@remix-run/node';
19+
import { useLoaderData } from '@remix-run/react';
20+
21+
import { Hit } from '../../components/Hit';
22+
import { Panel } from '../../components/Panel';
23+
import { ScrollTo } from '../../components/ScrollTo';
24+
import { NoResultsBoundary } from '../../components/NoResultsBoundary';
25+
import { SearchErrorToast } from '../../components/SearchErrorToast';
26+
27+
import tailwindStyles from '../tailwind.css';
28+
29+
const searchClient = algoliasearch(
30+
'latency',
31+
'6be0576ff61c053d5f9a3225e2a90f76'
32+
);
33+
34+
export const links: LinksFunction = () => [
35+
{ rel: 'stylesheet', href: instantSearchStyles },
36+
{ rel: 'stylesheet', href: tailwindStyles },
37+
];
38+
39+
export const loader: LoaderFunction = async ({ request }) => {
40+
const serverUrl = request.url;
41+
const serverState = await getServerState(<Search serverUrl={serverUrl} />);
42+
43+
return json({
44+
serverState,
45+
serverUrl,
46+
});
47+
};
48+
49+
type SearchProps = {
50+
serverState?: InstantSearchServerState;
51+
serverUrl?: string;
52+
};
53+
54+
function Search({ serverState, serverUrl }: SearchProps) {
55+
return (
56+
<InstantSearchSSRProvider {...serverState}>
57+
<InstantSearch
58+
searchClient={searchClient}
59+
indexName="instant_search"
60+
routing={{
61+
router: history({
62+
getLocation() {
63+
if (typeof window === 'undefined') {
64+
return new URL(serverUrl!) as unknown as Location;
65+
}
66+
67+
return window.location;
68+
},
69+
}),
70+
}}
71+
>
72+
<SearchErrorToast />
73+
74+
<ScrollTo className="max-w-6xl p-4 flex gap-4 m-auto">
75+
<div>
76+
<DynamicWidgets fallbackComponent={FallbackComponent} />
77+
</div>
78+
79+
<div className="flex flex-col w-full gap-8">
80+
<SearchBox />
81+
<NoResultsBoundary fallback={<NoResults />}>
82+
<Hits
83+
hitComponent={Hit}
84+
classNames={{
85+
list: 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4',
86+
item: 'p-2 w-full',
87+
}}
88+
/>
89+
<Pagination className="flex self-center" />
90+
</NoResultsBoundary>
91+
</div>
92+
</ScrollTo>
93+
</InstantSearch>
94+
</InstantSearchSSRProvider>
95+
);
96+
}
97+
98+
function FallbackComponent({ attribute }: { attribute: string }) {
99+
return (
100+
<Panel header={attribute}>
101+
<RefinementList attribute={attribute} />
102+
</Panel>
103+
);
104+
}
105+
106+
function NoResults() {
107+
const { indexUiState } = useInstantSearch();
108+
109+
return (
110+
<div>
111+
<p>
112+
No results for <q>{indexUiState.query}</q>.
113+
</p>
114+
</div>
115+
);
116+
}
117+
118+
export default function HomePage() {
119+
const { serverState, serverUrl } = useLoaderData();
120+
121+
return <Search serverState={serverState} serverUrl={serverUrl} />;
122+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Hit as AlgoliaHit } from 'instantsearch.js';
2+
import { Highlight } from 'react-instantsearch-hooks-web';
3+
4+
type HitProps = {
5+
hit: AlgoliaHit<{
6+
name: string;
7+
price: number;
8+
image: string;
9+
brand: string;
10+
}>;
11+
};
12+
13+
export function Hit({ hit }: HitProps) {
14+
return (
15+
<div className="group relative">
16+
<div className="flex justify-center overflow-hidden">
17+
<img
18+
src={hit.image}
19+
alt={hit.name}
20+
className="object-center object-cover"
21+
/>
22+
</div>
23+
<h3 className="mt-4 text-sm text-gray-700">
24+
<span className="absolute inset-0" />
25+
<Highlight hit={hit} attribute="name" />
26+
</h3>
27+
<p className="mt-1 text-sm text-gray-500">{hit.brand}</p>
28+
<p className="mt-1 text-sm font-medium text-gray-900">${hit.price}</p>
29+
</div>
30+
);
31+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { ReactNode } from 'react';
2+
import { useInstantSearch } from 'react-instantsearch-hooks-web';
3+
4+
type NoResultsBoundaryProps = {
5+
children: ReactNode;
6+
fallback: ReactNode;
7+
};
8+
9+
export function NoResultsBoundary({
10+
children,
11+
fallback,
12+
}: NoResultsBoundaryProps) {
13+
const { results } = useInstantSearch();
14+
15+
// The `__isArtificial` flag makes sure to not display the No Results message
16+
// when no hits have been returned yet.
17+
if (!results.__isArtificial && results.nbHits === 0) {
18+
return (
19+
<>
20+
{fallback}
21+
<div hidden>{children}</div>
22+
</>
23+
);
24+
}
25+
26+
return <>{children}</>;
27+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function Panel({
2+
children,
3+
header,
4+
footer,
5+
}: {
6+
children: React.ReactNode;
7+
header?: React.ReactNode;
8+
footer?: React.ReactNode;
9+
}) {
10+
return (
11+
<div className="ais-Panel">
12+
{header && <div className="ais-Panel-header">{header}</div>}
13+
<div className="ais-Panel-body">{children}</div>
14+
{footer && <div className="ais-Panel-footer">{footer}</div>}
15+
</div>
16+
);
17+
}

0 commit comments

Comments
 (0)