├── src ├── constants │ ├── colors.ts │ └── event-names.ts ├── react-app-env.d.ts ├── examples │ ├── SpiderManDemo │ │ ├── index.ts │ │ ├── FirstNameComponent.tsx │ │ ├── LastNameComponent.tsx │ │ ├── MovieCounterComponent.tsx │ │ ├── SpiderManContext.ts │ │ ├── SpiderManDemo.tsx │ │ └── NameComponent.tsx │ ├── MessagingDemo │ │ ├── Conversations │ │ │ ├── index.ts │ │ │ ├── Conversations.tsx │ │ │ ├── VanillaConversations.tsx │ │ │ ├── ContactListItem.tsx │ │ │ ├── useVanillaConversations.ts │ │ │ └── useConversations.ts │ │ ├── MessageHistory │ │ │ ├── index.tsx │ │ │ ├── MessageBubble.tsx │ │ │ ├── MessageLine.tsx │ │ │ ├── MessageHistory.tsx │ │ │ ├── VanillaMessageHistory.tsx │ │ │ ├── useVanillaMessageHistory.ts │ │ │ └── useMessageHistory.ts │ │ ├── types.ts │ │ ├── VanillaMessageHeader.tsx │ │ ├── MessageHeader.tsx │ │ ├── MessagingSubscriberContext.tsx │ │ ├── useFetchQuotes.tsx │ │ ├── PopularModeButton.tsx │ │ ├── useSubscribeMessageSocket.ts │ │ ├── VanillaMessagingContext.tsx │ │ ├── MessageInput.tsx │ │ ├── VanillaMessageInput.tsx │ │ ├── MessagingDemo.tsx │ │ └── FakeMessenger.ts │ ├── BasicDemo │ │ ├── colors.ts │ │ ├── BasicContext.tsx │ │ ├── BasicDemo.tsx │ │ ├── BasicItem.tsx │ │ └── BasicList.tsx │ ├── AdvancedDemo │ │ ├── colors.ts │ │ ├── AdvancedContext.tsx │ │ ├── AdvancedDemo.tsx │ │ ├── AdvancedItem.tsx │ │ └── AdvancedList.tsx │ ├── SubscriberDemo │ │ ├── colors.ts │ │ ├── SubscriberDemo.tsx │ │ ├── EmailItem.tsx │ │ ├── SubscriberContext.ts │ │ ├── SubscribedItem.tsx │ │ ├── NumElementsInput.tsx │ │ └── SubscriberList.tsx │ ├── ReactTrackDemo │ │ ├── ReactTrackContext.ts │ │ ├── Counter.tsx │ │ ├── TextBox.tsx │ │ └── ReactTrackDemo.tsx │ ├── DeepSubscriberDemo │ │ ├── PreviewFirstName.tsx │ │ ├── PreviewLastName.tsx │ │ ├── UserForm │ │ │ ├── NameInput.tsx │ │ │ ├── LastNameInput.tsx │ │ │ └── FirstNameInput.tsx │ │ ├── UserPreview.tsx │ │ ├── DeepSubscriberContext.ts │ │ └── DeepSubscriberDemo.tsx │ └── MemoDemo │ │ ├── ComponentD.tsx │ │ ├── ComponentB.tsx │ │ ├── MemoContext.tsx │ │ ├── ComponentA.tsx │ │ ├── MemoDemo.tsx │ │ └── ComponentC.tsx ├── components │ ├── PerformanceOptions │ │ ├── colors.ts │ │ ├── PerformanceOptionsContext.ts │ │ ├── PerformanceOptionsProvider.tsx │ │ └── PerformanceOptions.tsx │ ├── Input.tsx │ ├── Button.tsx │ └── LoadingSpinner.tsx ├── utils │ ├── logColor.ts │ ├── getIncrementedNumValue.ts │ ├── common-styles.ts │ ├── logRender.ts │ ├── getIncrementedCharValue.ts │ └── getRandomName.ts ├── types │ └── common-types.ts ├── react-subscribe-context │ ├── getUpdateEventName.ts │ ├── index.ts │ ├── context-control-types.ts │ ├── getStateChanges.ts │ ├── createProxyHandler.ts │ ├── getStateChanges.test.ts │ ├── useSubscribeProvider.ts │ ├── createSubscriberContext.tsx │ ├── createProxyHandler.test.ts │ ├── createSubscriberContext.test.tsx │ ├── useSubscribe.ts │ ├── useSubscribe.test.tsx │ └── useSubscribeProvider.test.ts ├── setupTests.ts ├── index.css ├── reportWebVitals.ts ├── index.tsx ├── App.css ├── logo.svg └── App.tsx ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .babelrc ├── .prettierrc ├── esbuild.config.js ├── .gitignore ├── tsconfig.json ├── webpack.config.js ├── package.json └── README.md /src/constants/colors.ts: -------------------------------------------------------------------------------- 1 | export const RENDER_COLOR = "#F2C57C"; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/examples/SpiderManDemo/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./SpiderManDemo"; 2 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/Conversations/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Conversations"; 2 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHistory/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./MessageHistory"; 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/components/PerformanceOptions/colors.ts: -------------------------------------------------------------------------------- 1 | export const PERFORMANCE_OPTIONS_COLOR = "#b399a2"; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vongdarakia/react-subscribe-context/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vongdarakia/react-subscribe-context/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vongdarakia/react-subscribe-context/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/utils/logColor.ts: -------------------------------------------------------------------------------- 1 | export const logColor = (color: string) => { 2 | return `color: ${color}`; 3 | }; 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", ["@babel/preset-react", { "runtime": "automatic" }]] 3 | } 4 | -------------------------------------------------------------------------------- /src/examples/BasicDemo/colors.ts: -------------------------------------------------------------------------------- 1 | export const BASIC_COLOR = "#a63a50"; 2 | export const BASIC_COLOR_LIGHT = "#b86173"; 3 | -------------------------------------------------------------------------------- /src/examples/AdvancedDemo/colors.ts: -------------------------------------------------------------------------------- 1 | export const ADVANCED_COLOR = "#7FB685"; 2 | export const ADVANCED_COLOR_LIGHT = "#99c59d"; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "printWidth": 100, 5 | "importOrder": ["^[./]"] 6 | } 7 | -------------------------------------------------------------------------------- /src/examples/SubscriberDemo/colors.ts: -------------------------------------------------------------------------------- 1 | export const SUBSCRIBER_COLOR = "#4f7cac"; 2 | export const SUBSCRIBER_COLOR_LIGHT = "#7296bd"; 3 | -------------------------------------------------------------------------------- /src/utils/getIncrementedNumValue.ts: -------------------------------------------------------------------------------- 1 | export const getIncrementedNumValue = (num: number) => { 2 | const nextNumber = num + 1; 3 | 4 | return nextNumber % 100; 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Input = styled("input")` 4 | padding: 8px; 5 | font-size: 16px; 6 | width: auto; 7 | `; 8 | -------------------------------------------------------------------------------- /src/types/common-types.ts: -------------------------------------------------------------------------------- 1 | import { DetailedHTMLProps, HTMLAttributes } from "react"; 2 | 3 | export type Style = DetailedHTMLProps, HTMLDivElement>["style"]; 4 | -------------------------------------------------------------------------------- /src/react-subscribe-context/getUpdateEventName.ts: -------------------------------------------------------------------------------- 1 | import { EventKey } from "."; 2 | 3 | export const getUpdateEventName = (key: string): EventKey => { 4 | return `update-${key}`; 5 | }; 6 | -------------------------------------------------------------------------------- /src/constants/event-names.ts: -------------------------------------------------------------------------------- 1 | export const EVT_MESSAGE_FROM_FRIEND = "message-from-friend"; 2 | export const EVT_MESSAGE_TO_FRIEND = "message-to-friend"; 3 | export const EVT_MESSAGE_READ_BY_FRIEND = "message-read-by-friend"; 4 | -------------------------------------------------------------------------------- /src/utils/common-styles.ts: -------------------------------------------------------------------------------- 1 | import { Style } from "definitions/common-types"; 2 | 3 | export const commonStyle: Style = { 4 | padding: 24, 5 | margin: 8, 6 | border: "1px solid white", 7 | display: "flex", 8 | }; 9 | -------------------------------------------------------------------------------- /src/react-subscribe-context/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./context-control-types"; 2 | export * from "./createSubscriberContext"; 3 | export * from "./getStateChanges"; 4 | export * from "./useSubscribe"; 5 | export * from "./useSubscribeProvider"; 6 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/utils/logRender.ts: -------------------------------------------------------------------------------- 1 | import { RENDER_COLOR } from "constants/colors"; 2 | import { logColor } from "./logColor"; 3 | 4 | export const logRender = (message: string, ...args: any[]) => { 5 | console.log(`%crender ${message}`, logColor(RENDER_COLOR), ...args); 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/getIncrementedCharValue.ts: -------------------------------------------------------------------------------- 1 | const charCodeA = "A".charCodeAt(0); 2 | 3 | export const getIncrementedCharValue = (char: string) => { 4 | const charCode = char.charCodeAt(0); 5 | const nextCharCode = ((charCode + 1 - charCodeA) % 26) + charCodeA; 6 | 7 | return String.fromCharCode(nextCharCode); 8 | }; 9 | -------------------------------------------------------------------------------- /esbuild.config.js: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild"); 2 | 3 | esbuild 4 | .build({ 5 | entryPoints: ["react-subscribe-context/index.ts"], 6 | bundle: true, 7 | outfile: "dist/index.js", 8 | sourcemap: true, 9 | minify: true, 10 | }) 11 | .catch(() => process.exit(1)); 12 | -------------------------------------------------------------------------------- /src/examples/ReactTrackDemo/ReactTrackContext.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { createContainer } from "react-tracked"; 3 | 4 | const useValue = () => 5 | useState({ 6 | count: 0, 7 | text: "hello", 8 | }); 9 | 10 | export const { Provider, useTracked } = createContainer(useValue); 11 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/types.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | name: string; 4 | } 5 | 6 | export interface MessageInfo { 7 | content: string; 8 | dateSeen?: string; 9 | dateSent: string; 10 | dateDelivered?: string; 11 | id: string; 12 | senderName: string; 13 | receiverName: string; 14 | status: "sent" | "delivered" | "seen"; 15 | } 16 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/examples/SpiderManDemo/FirstNameComponent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { useSubscribe } from "react-subscribe-context"; 3 | import { SpiderManContext } from "./SpiderManContext"; 4 | 5 | export const FirstNameComponent = (): ReactElement => { 6 | const [user] = useSubscribe(SpiderManContext, "user"); 7 | const { 8 | name: { first }, 9 | } = user; 10 | 11 | return
{first}
; 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | /.parcel-cache 27 | 28 | /.vscode 29 | 30 | *.tgz -------------------------------------------------------------------------------- /src/utils/getRandomName.ts: -------------------------------------------------------------------------------- 1 | const names = [ 2 | "Joe", 3 | "Joey", 4 | "Yugi", 5 | "Kaiba", 6 | "Goku", 7 | "Chichi", 8 | "Mimi", 9 | "Mini-me", 10 | "Ichigo", 11 | "Light", 12 | ]; 13 | 14 | export const getRandomName = (currentName: string) => { 15 | let nextName = currentName; 16 | 17 | while (nextName === currentName) { 18 | nextName = names[Math.floor(Math.random() * names.length)]; 19 | } 20 | 21 | return nextName; 22 | }; 23 | -------------------------------------------------------------------------------- /src/examples/SpiderManDemo/LastNameComponent.tsx: -------------------------------------------------------------------------------- 1 | // LastNameComponent.tsx 2 | import { ReactElement } from "react"; 3 | import { useSubscribe } from "react-subscribe-context"; 4 | import { SpiderManContext } from "./SpiderManContext"; 5 | 6 | export const LastNameComponent = (): ReactElement => { 7 | const [state] = useSubscribe(SpiderManContext); 8 | const { 9 | user: { 10 | name: { last }, 11 | }, 12 | } = state; 13 | 14 | return
{last}
; 15 | }; 16 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/PreviewFirstName.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 3 | import { logRender } from "utils/logRender"; 4 | import { DeepSubscriberContext } from "./DeepSubscriberContext"; 5 | 6 | export const PreviewFirstName = (): ReactElement => { 7 | const [state] = useSubscribe(DeepSubscriberContext); 8 | 9 | logRender("firstName Preview"); 10 | 11 | return {state.user.name.first}; 12 | }; 13 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/PreviewLastName.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 3 | import { logRender } from "utils/logRender"; 4 | import { DeepSubscriberContext } from "./DeepSubscriberContext"; 5 | 6 | export const PreviewLastName = (): ReactElement => { 7 | const [user] = useSubscribe(DeepSubscriberContext, "user"); 8 | 9 | logRender("lastName Preview"); 10 | 11 | return {user.name.last}; 12 | }; 13 | -------------------------------------------------------------------------------- /src/examples/BasicDemo/BasicContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | interface BasicContextState { 4 | setState: (nextState: { [key: `basic-prop-${number}`]: number }) => void; 5 | setValue: (key: `basic-prop-${number}`, value: number) => void; 6 | [key: `basic-prop-${number}`]: number; 7 | } 8 | 9 | export const basicContextState: BasicContextState = { 10 | setState: () => {}, 11 | setValue: () => {}, 12 | }; 13 | 14 | export const BasicContext = createContext(basicContextState); 15 | -------------------------------------------------------------------------------- /src/examples/AdvancedDemo/AdvancedContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export interface AdvancedContextState { 4 | setState: (nextState: { [key: `advanced-prop-${number}`]: number }) => void; 5 | setValue: (key: `advanced-prop-${number}`, value: number) => void; 6 | [key: `advanced-prop-${number}`]: number; 7 | } 8 | 9 | export const advancedContextState: AdvancedContextState = { 10 | setState: () => {}, 11 | setValue: () => {}, 12 | }; 13 | 14 | export const AdvancedContext = createContext(advancedContextState); 15 | -------------------------------------------------------------------------------- /src/examples/MemoDemo/ComponentD.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { commonStyle } from "utils/common-styles"; 3 | 4 | export const ComponentD = ({ showRendered }: { showRendered: boolean }): ReactElement => { 5 | return ( 6 |
10 |
11 | Component D 12 |
13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/UserForm/NameInput.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { FirstNameInput } from "./FirstNameInput"; 3 | import { LastNameInput } from "./LastNameInput"; 4 | 5 | export const NameInput = (): ReactElement => { 6 | return ( 7 |
8 |

Inputs

9 |
10 | 11 | 12 |
13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/examples/MemoDemo/ComponentB.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { commonStyle } from "utils/common-styles"; 3 | import { ComponentC } from "./ComponentC"; 4 | 5 | export const ComponentB = (): ReactElement => { 6 | return ( 7 |
15 |
Component B
16 | 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/VanillaMessageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { VanillaMessagingContext } from "examples/MessagingDemo/VanillaMessagingContext"; 2 | import { ReactElement, useContext } from "react"; 3 | import styled from "styled-components"; 4 | 5 | export const VanillaMessageHeader = (): ReactElement => { 6 | const { selectedReceiverName: receiverName } = useContext(VanillaMessagingContext); 7 | 8 | return {receiverName}; 9 | }; 10 | 11 | const StyledHeader = styled.div` 12 | margin-bottom: 12px; 13 | text-align: left; 14 | font-weight: bold; 15 | font-size: 24px; 16 | `; 17 | -------------------------------------------------------------------------------- /src/examples/MemoDemo/MemoContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const defaultMemoState = { 4 | c: 0, 5 | }; 6 | 7 | export const defaultMemoContextState = { 8 | showRendered: false, 9 | state: defaultMemoState, 10 | setState: () => {}, 11 | toggleShowRendered: () => {}, 12 | }; 13 | 14 | export interface MemoContextState { 15 | showRendered: boolean; 16 | state: typeof defaultMemoState; 17 | setState: (key: keyof typeof defaultMemoState, value: number) => void; 18 | toggleShowRendered: () => void; 19 | } 20 | 21 | export const MemoContext = createContext(defaultMemoContextState); 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "react-app-polyfill/ie11"; 3 | import "react-app-polyfill/stable"; 4 | import ReactDOM from "react-dom"; 5 | import App from "./App"; 6 | import "./index.css"; 7 | import reportWebVitals from "./reportWebVitals"; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById("root") 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { MessagingSubscriberContext } from "examples/MessagingDemo/MessagingSubscriberContext"; 2 | import { ReactElement } from "react"; 3 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 4 | import styled from "styled-components"; 5 | 6 | export const MessageHeader = (): ReactElement => { 7 | const [receiverName] = useSubscribe(MessagingSubscriberContext, "selectedReceiverName"); 8 | 9 | return {receiverName}; 10 | }; 11 | 12 | const StyledHeader = styled.div` 13 | margin-bottom: 12px; 14 | text-align: left; 15 | font-weight: bold; 16 | font-size: 24px; 17 | `; 18 | -------------------------------------------------------------------------------- /src/examples/ReactTrackDemo/Counter.tsx: -------------------------------------------------------------------------------- 1 | import { logRender } from "utils/logRender"; 2 | import { useTracked } from "./ReactTrackContext"; 3 | 4 | export const Counter = () => { 5 | const [state, setState] = useTracked(); 6 | 7 | const increment = () => { 8 | setState((prev) => ({ 9 | ...prev, 10 | count: prev.count + 1, 11 | })); 12 | }; 13 | logRender("Counter"); 14 | 15 | return ( 16 |
17 |
Count: {state.count}
18 | 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/examples/MemoDemo/ComponentA.tsx: -------------------------------------------------------------------------------- 1 | import { memo, ReactElement } from "react"; 2 | import { commonStyle } from "utils/common-styles"; 3 | import { ComponentB } from "./ComponentB"; 4 | 5 | export const ComponentA = memo((): ReactElement => { 6 | // const { 7 | // state: { a }, 8 | // setState, 9 | // } = useContext(BasicContext); 10 | 11 | return ( 12 |
13 |
Component A (memoized)
14 | {/* */} 15 | 16 |
17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessagingSubscriberContext.tsx: -------------------------------------------------------------------------------- 1 | import { Conversation } from "examples/MessagingDemo/FakeMessenger"; 2 | import { MessageInfo, User } from "examples/MessagingDemo/types"; 3 | import { createSubscriberContext } from "react-subscribe-context/createSubscriberContext"; 4 | 5 | const initialState = { 6 | conversations: [] as Conversation[], 7 | currentMessages: [] as MessageInfo[], 8 | currentUser: { 9 | id: "my-user-id", 10 | name: "Akia Vongdara", 11 | } as User, 12 | selectedReceiverName: "", 13 | }; 14 | 15 | export const { Context: MessagingSubscriberContext, Provider: MessagingSubscriberProvider } = 16 | createSubscriberContext({ initialState }); 17 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/useFetchQuotes.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useEffect, useState } from "react"; 3 | 4 | const cache = { 5 | quotes: null, 6 | }; 7 | 8 | export const useFetchQuotes = () => { 9 | const [quotes, setQuotes] = useState([]); 10 | 11 | useEffect(() => { 12 | const fetchQuotes = async () => { 13 | if (cache.quotes) { 14 | return setQuotes(cache.quotes); 15 | } 16 | 17 | const results = await axios.get("https://type.fit/api/quotes"); 18 | 19 | cache.quotes = results.data; 20 | setQuotes(results.data); 21 | }; 22 | 23 | fetchQuotes(); 24 | }, []); 25 | 26 | return quotes; 27 | }; 28 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/UserPreview.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import styled from "styled-components"; 3 | import { PreviewFirstName } from "./PreviewFirstName"; 4 | import { PreviewLastName } from "./PreviewLastName"; 5 | 6 | const StyledContainer = styled.div` 7 | margin: 0 24px; 8 | color: whitesmoke; 9 | text-align: left; 10 | 11 | span { 12 | font-size: 24px; 13 | letter-spacing: 2px; 14 | margin-right: 8px; 15 | } 16 | `; 17 | 18 | export const UserPreview = (): ReactElement => { 19 | return ( 20 | 21 |

Preview

22 | 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ReactChild, ReactChildren } from "react"; 2 | import styled from "styled-components"; 3 | 4 | export const Button = styled.button<{ 5 | hoverColor?: string; 6 | backgroundColor?: string; 7 | children?: ReactChild | ReactChildren; 8 | }>` 9 | padding: 8px; 10 | width: 40px; 11 | height: 40px; 12 | font-family: "Roboto Mono", monospace; 13 | font-weight: 600; 14 | background-color: ${(props) => props.backgroundColor}; 15 | cursor: ${(props) => (props.onClick ? "pointer" : "initial")}; 16 | color: whitesmoke; 17 | border: 1px solid whitesmoke; 18 | border-radius: 4px; 19 | 20 | :hover { 21 | background-color: ${(props) => props.hoverColor}; //#7296bd; 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /src/components/PerformanceOptions/PerformanceOptionsContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | const DEFAULT_NUM_ITEMS = 10; 4 | 5 | export interface PerformanceOptionsState { 6 | numElements: number; 7 | shouldUseMemo: boolean; 8 | } 9 | 10 | export interface PerformanceOptionsContextState { 11 | state: PerformanceOptionsState; 12 | setState: (nextState: Partial) => void; 13 | } 14 | 15 | export const defaultPerformanceContextState: PerformanceOptionsContextState = { 16 | state: { 17 | numElements: DEFAULT_NUM_ITEMS, 18 | shouldUseMemo: false, 19 | }, 20 | setState: () => {}, 21 | }; 22 | 23 | export const PerformanceOptionsContext = createContext(defaultPerformanceContextState); 24 | -------------------------------------------------------------------------------- /src/examples/ReactTrackDemo/TextBox.tsx: -------------------------------------------------------------------------------- 1 | import { getIncrementedCharValue } from "utils/getIncrementedCharValue"; 2 | import { logRender } from "utils/logRender"; 3 | import { useTracked } from "./ReactTrackContext"; 4 | 5 | export const TextBox = () => { 6 | const [state, setState] = useTracked(); 7 | const increment = () => { 8 | setState((prev) => ({ 9 | ...prev, 10 | text: getIncrementedCharValue(prev.text), 11 | })); 12 | }; 13 | logRender("TextBox"); 14 | 15 | return ( 16 |
17 |
Text: {state.text}
18 | 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/DeepSubscriberContext.ts: -------------------------------------------------------------------------------- 1 | import { createSubscriberContext } from "react-subscribe-context/createSubscriberContext"; 2 | 3 | type Email = `${string}@${string}.${string}`; 4 | 5 | interface Name { 6 | first: string; 7 | last: string; 8 | } 9 | 10 | interface User { 11 | name: Name; 12 | email: Email; 13 | age: number; 14 | } 15 | 16 | const initialState: { 17 | user: User; 18 | } = { 19 | user: { 20 | name: { first: "Jim", last: "Halpert" }, 21 | email: "lkang@gmail.com", 22 | age: 24, 23 | }, 24 | }; 25 | 26 | export type DeepSubscriberState = typeof initialState; 27 | 28 | export const [DeepSubscriberContext, DeepSubscriberProvider] = createSubscriberContext({ 29 | initialState, 30 | }); 31 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/PopularModeButton.tsx: -------------------------------------------------------------------------------- 1 | import { FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 2 | import { ReactElement, useState } from "react"; 3 | import styled from "styled-components"; 4 | 5 | export const PopularModeButton = (): ReactElement => { 6 | const [isSimulating, setIsSimulating] = useState(false); 7 | 8 | const startPopularMode = async () => { 9 | setIsSimulating(true); 10 | 11 | await FakeMessenger.simulatePopularMode("Akia Vongdara"); 12 | 13 | setIsSimulating(false); 14 | }; 15 | 16 | return ( 17 | 18 | Popular Mode 19 | 20 | ); 21 | }; 22 | 23 | const StyledButton = styled.button` 24 | padding: 12px; 25 | `; 26 | -------------------------------------------------------------------------------- /src/examples/SpiderManDemo/MovieCounterComponent.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { useSubscribe } from "react-subscribe-context"; 3 | import styled from "styled-components"; 4 | import { SpiderManContext } from "./SpiderManContext"; 5 | 6 | export const MovieCounterComponent = (): ReactElement => { 7 | const { 8 | value: movieCounter, 9 | contextControl: { actions }, 10 | } = useSubscribe(SpiderManContext, "movieCounter"); 11 | 12 | const handleClickCounter = () => { 13 | actions.incrementMovieCounter(); 14 | }; 15 | 16 | return {movieCounter}; 17 | }; 18 | 19 | const StyledButton = styled("button")` 20 | padding: 12px; 21 | font-size: 16px; 22 | font-weight: bold; 23 | `; 24 | -------------------------------------------------------------------------------- /src/examples/SubscriberDemo/SubscriberDemo.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptions } from "components/PerformanceOptions/PerformanceOptions"; 2 | import { ReactElement, useEffect } from "react"; 3 | import { logColor } from "utils/logColor"; 4 | import { logRender } from "utils/logRender"; 5 | import { SUBSCRIBER_COLOR } from "./colors"; 6 | import { SubscriberContext } from "./SubscriberContext"; 7 | import { SubscriberList } from "./SubscriberList"; 8 | 9 | export const SubscriberDemo = (): ReactElement => { 10 | useEffect(() => { 11 | logRender("%cSubscriberProvider", logColor(SUBSCRIBER_COLOR)); 12 | }); 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/examples/SubscriberDemo/EmailItem.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "components/Button"; 2 | import { PERFORMANCE_OPTIONS_COLOR } from "components/PerformanceOptions/colors"; 3 | import { ReactElement } from "react"; 4 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 5 | import { SubscriberContext } from "./SubscriberContext"; 6 | 7 | export const EmailItem = (): ReactElement => { 8 | const [state] = useSubscribe(SubscriberContext.Context); 9 | 10 | return ( 11 |
12 | 15 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/DeepSubscriberDemo.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import styled from "styled-components"; 3 | import { DeepSubscriberProvider } from "./DeepSubscriberContext"; 4 | import { NameInput } from "./UserForm/NameInput"; 5 | import { UserPreview } from "./UserPreview"; 6 | 7 | const StyledContainer = styled.div` 8 | display: flex; 9 | color: whitesmoke; 10 | `; 11 | 12 | export const DeepSubscriberDemo = (): ReactElement => { 13 | return ( 14 | 15 | 16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/examples/ReactTrackDemo/ReactTrackDemo.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptions } from "components/PerformanceOptions/PerformanceOptions"; 2 | import { PerformanceOptionsContext } from "components/PerformanceOptions/PerformanceOptionsContext"; 3 | import { ReactElement, useContext } from "react"; 4 | import { logRender } from "utils/logRender"; 5 | import { Counter } from "./Counter"; 6 | import { Provider } from "./ReactTrackContext"; 7 | import { TextBox } from "./TextBox"; 8 | 9 | export const ReactTrackDemo = (): ReactElement => { 10 | logRender("ReactTrackedDemo"); 11 | useContext(PerformanceOptionsContext); 12 | 13 | return ( 14 | 15 | 16 |
17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHistory/MessageBubble.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import styled from "styled-components"; 3 | 4 | interface Props { 5 | message: string; 6 | isSender: boolean; 7 | } 8 | 9 | export const MessageBubble = ({ message, isSender }: Props): ReactElement => { 10 | return ( 11 | 12 | {message} 13 | 14 | ); 15 | }; 16 | 17 | const StyledMessageBubble = styled.div` 18 | border-radius: 12px; 19 | padding: 12px 16px; 20 | line-height: 1.5; 21 | max-width: 66%; 22 | text-align: left; 23 | 24 | &.sender-bubble { 25 | background-color: #1a233b; 26 | color: #e7e8ea; 27 | } 28 | 29 | &.receiver-bubble { 30 | background-color: #fff; 31 | box-shadow: 0px 4px 4px -2px #d4d4d4; 32 | color: #727a8c; 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /src/examples/SpiderManDemo/SpiderManContext.ts: -------------------------------------------------------------------------------- 1 | import { BaseContextControl } from "react-subscribe-context"; 2 | import { createSubscriberContext } from "react-subscribe-context/createSubscriberContext"; 3 | 4 | const initialState = { 5 | user: { 6 | name: { 7 | first: "Peter", 8 | last: "Parker", 9 | }, 10 | }, 11 | movieCounter: 9, 12 | }; 13 | 14 | type State = typeof initialState; 15 | 16 | const createActions = (contextControl: BaseContextControl) => { 17 | const { setValue } = contextControl; 18 | 19 | return { 20 | incrementMovieCounter: () => { 21 | setValue("movieCounter", (movieCounter) => movieCounter + 1); 22 | }, 23 | }; 24 | }; 25 | 26 | export const { 27 | Context: SpiderManContext, 28 | Provider: SpiderManProvider, // Note: This is not the same as what Context.Provider returns 29 | } = createSubscriberContext({ initialState, createActions }); 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "isolatedModules": true, 13 | "noEmit": false, 14 | "jsx": "preserve", 15 | "baseUrl": "./src", 16 | "outDir": "./dist/typings", 17 | "paths": { 18 | "components/*": ["components/*"], 19 | "constants/*": ["constants/*"], 20 | "definitions/*": ["types/*"], 21 | "examples/*": ["examples/*"], 22 | "react-subscribe-context/*": ["react-subscribe-context/*"], 23 | "utils/*": ["utils/*"] 24 | }, 25 | "sourceMap": true, 26 | "declaration": true 27 | }, 28 | "include": ["src/react-subscribe-context"], 29 | "exclude": ["src/**/*test*"] 30 | } 31 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHistory/MessageLine.tsx: -------------------------------------------------------------------------------- 1 | import { MessageBubble } from "examples/MessagingDemo/MessageHistory/MessageBubble"; 2 | import { MessageInfo } from "examples/MessagingDemo/types"; 3 | import { ReactElement } from "react"; 4 | import styled from "styled-components"; 5 | 6 | interface Props { 7 | isSender: boolean; 8 | messageInfo: MessageInfo; 9 | } 10 | 11 | export const MessageLine = ({ isSender, messageInfo }: Props): ReactElement => { 12 | return ( 13 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | const StyledMessageLine = styled.div<{ isSender?: boolean }>` 23 | display: flex; 24 | flex-direction: column; 25 | 26 | &.sender-line { 27 | align-items: flex-end; 28 | } 29 | 30 | &.receiver-line { 31 | align-items: flex-start; 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/UserForm/LastNameInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "components/Input"; 2 | import { ChangeEventHandler, ReactElement } from "react"; 3 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 4 | import { logRender } from "utils/logRender"; 5 | import { DeepSubscriberContext } from "../DeepSubscriberContext"; 6 | 7 | export const LastNameInput = (): ReactElement => { 8 | const [ 9 | { 10 | name: { last }, 11 | }, 12 | setValue, 13 | ] = useSubscribe(DeepSubscriberContext, "user"); 14 | 15 | const handleChangeLastName: ChangeEventHandler = (e) => { 16 | const last = e.target.value; 17 | 18 | setValue((user) => ({ ...user, name: { ...user.name, last } })); 19 | }; 20 | 21 | logRender("lastName Input"); 22 | 23 | return ( 24 |
25 | 26 | 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App main { 6 | padding: 48px; 7 | } 8 | 9 | * { 10 | font-family: "Roboto", sans-serif; 11 | } 12 | 13 | .App-logo { 14 | height: 40vmin; 15 | pointer-events: none; 16 | } 17 | 18 | @media (prefers-reduced-motion: no-preference) { 19 | .App-logo { 20 | animation: App-logo-spin infinite 20s linear; 21 | } 22 | } 23 | 24 | .App-header { 25 | background-color: #282c34; 26 | min-height: 100vh; 27 | display: flex; 28 | flex-direction: column; 29 | align-items: center; 30 | justify-content: center; 31 | font-size: calc(10px + 2vmin); 32 | color: white; 33 | } 34 | 35 | .App-link { 36 | color: #61dafb; 37 | } 38 | 39 | @keyframes App-logo-spin { 40 | from { 41 | transform: rotate(0deg); 42 | } 43 | to { 44 | transform: rotate(360deg); 45 | } 46 | } 47 | 48 | .rendered-component { 49 | border-width: 2px !important; 50 | border-color: #ef6f6c !important; 51 | } 52 | 53 | .rendered-component .text { 54 | color: #ef6f6c; 55 | font-weight: bold; 56 | } 57 | -------------------------------------------------------------------------------- /src/components/PerformanceOptions/PerformanceOptionsProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useState } from "react"; 2 | import { logColor } from "utils/logColor"; 3 | import { logRender } from "utils/logRender"; 4 | import { PERFORMANCE_OPTIONS_COLOR } from "./colors"; 5 | import { 6 | defaultPerformanceContextState, 7 | PerformanceOptionsContext, 8 | PerformanceOptionsContextState, 9 | } from "./PerformanceOptionsContext"; 10 | 11 | export const PerformanceOptionsProvider = ({ 12 | children, 13 | }: { 14 | children: ReactElement | ReactElement[]; 15 | }): ReactElement => { 16 | const [state, setState] = useState(defaultPerformanceContextState.state); 17 | 18 | const contextState: PerformanceOptionsContextState = { 19 | state, 20 | setState: (nextState) => setState({ ...state, ...nextState }), 21 | }; 22 | 23 | logRender("%cPerformanceOptionsProvider", logColor(PERFORMANCE_OPTIONS_COLOR)); 24 | 25 | return ( 26 | 27 | {children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/examples/DeepSubscriberDemo/UserForm/FirstNameInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "components/Input"; 2 | import { ChangeEventHandler, ReactElement } from "react"; 3 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 4 | import { logRender } from "utils/logRender"; 5 | import { DeepSubscriberContext } from "../DeepSubscriberContext"; 6 | 7 | export const FirstNameInput = (): ReactElement => { 8 | const [ 9 | { 10 | user: { name }, 11 | }, 12 | setState, 13 | ] = useSubscribe(DeepSubscriberContext); 14 | 15 | const handleChangeFirstName: ChangeEventHandler = (e) => { 16 | const first = e.target.value; 17 | 18 | console.log({ first }); 19 | 20 | setState(({ user }) => ({ 21 | user: { ...user, name: { ...user.name, first } }, 22 | })); 23 | }; 24 | 25 | logRender("firstName Input"); 26 | 27 | return ( 28 |
29 | 30 | 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/useSubscribeMessageSocket.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EVT_MESSAGE_FROM_FRIEND, 3 | EVT_MESSAGE_READ_BY_FRIEND, 4 | EVT_MESSAGE_TO_FRIEND, 5 | } from "constants/event-names"; 6 | import { FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 7 | import { MessageInfo } from "examples/MessagingDemo/types"; 8 | import { useEffect } from "react"; 9 | 10 | const messageEvents = [ 11 | EVT_MESSAGE_FROM_FRIEND, 12 | EVT_MESSAGE_TO_FRIEND, 13 | EVT_MESSAGE_READ_BY_FRIEND, 14 | ] as const; 15 | 16 | type MessageEvent = typeof messageEvents[number]; 17 | 18 | interface SubscribeMessageSocket { 19 | // (event: typeof EVT_MESSAGE_FROM_FRIEND, eventHandler: (messageInfo: MessageInfo) => void): void; 20 | // (event: typeof EVT_MESSAGE_TO_FRIEND, eventHandler: (messageInfo: MessageInfo) => void): void; 21 | (event: MessageEvent, eventHandler: (messageInfo: MessageInfo) => void): void; 22 | } 23 | 24 | export const useSubscribeMessageSocket: SubscribeMessageSocket = (event, eventHandler) => { 25 | const emitter = FakeMessenger.getEmitter(); 26 | 27 | useEffect(() => { 28 | emitter.on(event, eventHandler); 29 | 30 | return () => { 31 | emitter.off(event, eventHandler); 32 | }; 33 | }, [emitter, event, eventHandler]); 34 | }; 35 | -------------------------------------------------------------------------------- /src/examples/BasicDemo/BasicDemo.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptions } from "components/PerformanceOptions/PerformanceOptions"; 2 | import { ReactElement, useState } from "react"; 3 | import { logColor } from "utils/logColor"; 4 | import { logRender } from "utils/logRender"; 5 | import { BasicContext, basicContextState } from "./BasicContext"; 6 | import { BasicList } from "./BasicList"; 7 | import { BASIC_COLOR } from "./colors"; 8 | 9 | export const BasicDemo = (): ReactElement => { 10 | const [state, setState] = useState(basicContextState); 11 | 12 | const handleSetValue: typeof basicContextState["setValue"] = (key, value) => { 13 | const newState: typeof state = { ...state }; 14 | 15 | newState[key] = value; 16 | 17 | setState(newState); 18 | }; 19 | 20 | const handleSetState: typeof basicContextState["setState"] = (nextState) => { 21 | const newState: typeof state = { ...state, ...nextState }; 22 | 23 | setState(newState); 24 | }; 25 | 26 | logRender("%cBasicProvider", logColor(BASIC_COLOR)); 27 | 28 | return ( 29 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/react-subscribe-context/context-control-types.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | type Key = keyof TState & string; 4 | 5 | export type EventKey = `update-${string}`; 6 | 7 | export interface SetValue { 8 | >(key: TKey, nextValue: TState[TKey]): void | Promise; 9 | >( 10 | key: TKey, 11 | getNextValue: (value: TState[TKey], state: TState) => TState[TKey] 12 | ): void | Promise; 13 | } 14 | 15 | export interface SetState { 16 | (nextState: Partial): void | Promise; 17 | (getNextState: (state: TState) => Partial): void | Promise; 18 | } 19 | 20 | export type GetValue = >(key: TKey) => TState[TKey]; 21 | 22 | export type GetState = () => TState; 23 | 24 | export interface BaseContextControl { 25 | emitter: EventEmitter; 26 | getValue: GetValue; 27 | getState: GetState; 28 | setValue: SetValue; 29 | setState: SetState; 30 | } 31 | 32 | export interface ContextControl 33 | extends BaseContextControl { 34 | actions: TActions; 35 | } 36 | 37 | export type ActionsCreator = ( 38 | contextControl: BaseContextControl 39 | ) => TActions; 40 | -------------------------------------------------------------------------------- /src/examples/SpiderManDemo/SpiderManDemo.tsx: -------------------------------------------------------------------------------- 1 | // App.tsx 2 | import React, { ReactElement } from "react"; 3 | import styled from "styled-components"; 4 | import { MovieCounterComponent } from "./MovieCounterComponent"; 5 | import { NameComponent } from "./NameComponent"; 6 | import { SpiderManProvider } from "./SpiderManContext"; 7 | 8 | export const SpiderManDemo = (): ReactElement => { 9 | return ( 10 | 11 | 12 | 13 |
{"Name: "}
14 | 15 |
16 | 17 |
{"Movie Counter: "}
18 | 19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | const StyledWrapper = styled("div")` 26 | display: flex; 27 | flex-direction: column; 28 | align-items: flex-start; 29 | gap: 12px; 30 | `; 31 | 32 | const StyledComponentWrapper = styled("div")` 33 | display: flex; 34 | gap: 12px; 35 | 36 | .component-label { 37 | width: 120px; 38 | color: white; 39 | text-align: right; 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /src/examples/AdvancedDemo/AdvancedDemo.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptions } from "components/PerformanceOptions/PerformanceOptions"; 2 | import { ReactElement, useCallback, useRef, useState } from "react"; 3 | import { logColor } from "utils/logColor"; 4 | import { logRender } from "utils/logRender"; 5 | import { AdvancedContext, AdvancedContextState, advancedContextState } from "./AdvancedContext"; 6 | import { AdvancedList } from "./AdvancedList"; 7 | import { ADVANCED_COLOR } from "./colors"; 8 | 9 | export const AdvancedDemo = (): ReactElement => { 10 | const state = useRef(advancedContextState); 11 | const [, setFakeValue] = useState({}); 12 | const rerender = useCallback(() => setFakeValue({}), []); 13 | 14 | const handleSetState: typeof advancedContextState["setState"] = (nextState) => { 15 | Object.keys(nextState).forEach((k) => { 16 | const key = k as `advanced-prop-${number}`; 17 | state.current[key] = nextState[key]; 18 | }); 19 | 20 | rerender(); 21 | }; 22 | 23 | state.current.setState = handleSetState; 24 | 25 | logRender("%cAdvancedProvider", logColor(ADVANCED_COLOR)); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | target: "web", 5 | entry: "./src/react-subscribe-context/index.ts", 6 | output: { 7 | filename: "bundle.js", 8 | path: path.resolve(__dirname, "dist"), 9 | libraryTarget: "umd", 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.(js|jsx|ts|tsx)$/, 15 | use: { 16 | loader: "babel-loader", 17 | }, 18 | exclude: /node_modules/, 19 | }, 20 | { 21 | test: /\.(ts|tsx)$/, 22 | use: "ts-loader", 23 | exclude: /node_modules/, 24 | }, 25 | ], 26 | }, 27 | resolve: { 28 | extensions: [".tsx", ".ts", ".js", ".jsx"], 29 | alias: { 30 | components: path.resolve(__dirname, "src/components"), 31 | constants: path.resolve(__dirname, "src/constants"), 32 | definitions: path.resolve(__dirname, "src/types"), 33 | examples: path.resolve(__dirname, "src/examples"), 34 | "react-subscribe-context": path.resolve(__dirname, "src/react-subscribe-context"), 35 | utils: path.resolve(__dirname, "src/utils"), 36 | }, 37 | }, 38 | externals: { 39 | react: "react", 40 | }, 41 | mode: "production", 42 | }; 43 | -------------------------------------------------------------------------------- /src/examples/AdvancedDemo/AdvancedItem.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "components/Button"; 2 | import { Style } from "definitions/common-types"; 3 | import React, { ReactElement, useContext, useEffect } from "react"; 4 | import { logColor } from "utils/logColor"; 5 | import { logRender } from "utils/logRender"; 6 | import { AdvancedContext } from "./AdvancedContext"; 7 | import { ADVANCED_COLOR, ADVANCED_COLOR_LIGHT } from "./colors"; 8 | 9 | const containerStyle: Style = { 10 | padding: 2, 11 | margin: 2, 12 | display: "inline-block", 13 | flex: 1, 14 | }; 15 | 16 | export const AdvancedItem = ({ 17 | itemKey, 18 | }: { 19 | itemKey: `advanced-prop-${number}`; 20 | value?: number; 21 | }): ReactElement => { 22 | const { setValue, ...props } = useContext(AdvancedContext); 23 | 24 | const propVal = props[itemKey]; 25 | 26 | useEffect(() => { 27 | console.log("mounted", itemKey); 28 | }, [itemKey]); 29 | 30 | logRender("%cAdvancedItem", logColor(ADVANCED_COLOR_LIGHT)); 31 | 32 | return ( 33 |
34 | 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/examples/SubscriberDemo/SubscriberContext.ts: -------------------------------------------------------------------------------- 1 | import { createSubscriberContext } from "react-subscribe-context/createSubscriberContext"; 2 | import { getIncrementedCharValue } from "utils/getIncrementedCharValue"; 3 | import { getIncrementedNumValue } from "utils/getIncrementedNumValue"; 4 | 5 | export type NumberValueKey = `prop-num-${number}`; 6 | export type StringValueKey = `prop-str-${number}`; 7 | export type SubscriberKey = NumberValueKey | StringValueKey; 8 | 9 | export const isNumberValueKey = (key: SubscriberKey): key is NumberValueKey => { 10 | return key.includes("prop-num-"); 11 | }; 12 | 13 | export const isStringValueKey = (key: SubscriberKey): key is StringValueKey => { 14 | return key.includes("prop-str-"); 15 | }; 16 | 17 | const initialState: { 18 | [key: NumberValueKey]: number; 19 | [key: StringValueKey]: string; 20 | email?: string; 21 | } = {}; 22 | 23 | export const NUM_SUBSCRIBED_ITEMS = 10; 24 | 25 | let num = 0; 26 | let char = "A"; 27 | 28 | for (let i = 0; i < NUM_SUBSCRIBED_ITEMS; i++) { 29 | if (i % 2 === 0) { 30 | initialState[`prop-num-${i}`] = num; 31 | num = getIncrementedNumValue(num); 32 | } else { 33 | initialState[`prop-str-${i}`] = char; 34 | char = getIncrementedCharValue(char); 35 | } 36 | } 37 | 38 | export type SubscriberState = typeof initialState; 39 | 40 | export const SubscriberContext = createSubscriberContext({ initialState }); 41 | -------------------------------------------------------------------------------- /src/examples/MemoDemo/MemoDemo.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useState } from "react"; 2 | import { ComponentA } from "./ComponentA"; 3 | import { defaultMemoState, MemoContext, MemoContextState } from "./MemoContext"; 4 | 5 | export const MemoDemo = (): ReactElement => { 6 | const [state, setState] = useState(defaultMemoState); 7 | const [showRendered, setShowRendered] = useState(false); 8 | 9 | const updateValue: MemoContextState["setState"] = (key, value) => { 10 | setState({ ...state, [key]: value }); 11 | }; 12 | 13 | const toggleShowRendered = () => { 14 | setShowRendered(!showRendered); 15 | }; 16 | 17 | const contextState = { 18 | state, 19 | setState: updateValue, 20 | toggleShowRendered, 21 | showRendered, 22 | }; 23 | 24 | return ( 25 | 26 |
35 |
36 | Provider Component 37 |
38 | 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | export const LoadingSpinner = () => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | const StyledContainer = styled.div` 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | width: 100%; 17 | height: 100%; 18 | `; 19 | 20 | const SPINNER_SIZE = "20px"; 21 | const SPINNER_COLOR = "#004dfc"; 22 | const SPINNER_BORDER_SIZE = "2px"; 23 | 24 | const StyledSpinner = styled.div` 25 | position: relative; 26 | box-sizing: border-box; 27 | 28 | &::after { 29 | position: relative; 30 | box-sizing: border-box; 31 | } 32 | 33 | width: ${SPINNER_SIZE}; 34 | height: ${SPINNER_SIZE}; 35 | display: block; 36 | color: ${SPINNER_COLOR}; 37 | 38 | &:after { 39 | content: ""; 40 | width: 100%; 41 | height: 100%; 42 | display: inline-block; 43 | border: ${SPINNER_BORDER_SIZE} solid currentColor; 44 | border-bottom-color: transparent; 45 | border-radius: 100%; 46 | background: transparent; 47 | 48 | animation: ball-clip-rotate 0.75s linear infinite; 49 | } 50 | 51 | @keyframes ball-clip-rotate { 52 | 0% { 53 | transform: rotate(0deg); 54 | } 55 | 100% { 56 | transform: rotate(360deg); 57 | } 58 | } 59 | `; 60 | -------------------------------------------------------------------------------- /src/examples/BasicDemo/BasicItem.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "components/Button"; 2 | import { Style } from "definitions/common-types"; 3 | import { ReactElement, useContext, useEffect } from "react"; 4 | import { getIncrementedNumValue } from "utils/getIncrementedNumValue"; 5 | import { logColor } from "utils/logColor"; 6 | import { logRender } from "utils/logRender"; 7 | import { BasicContext } from "./BasicContext"; 8 | import { BASIC_COLOR, BASIC_COLOR_LIGHT } from "./colors"; 9 | 10 | const containerStyle: Style = { 11 | padding: 2, 12 | margin: 2, 13 | display: "inline-block", 14 | flex: 1, 15 | }; 16 | 17 | export const BasicItem = ({ 18 | itemKey, 19 | }: { 20 | itemKey: `basic-prop-${number}`; 21 | value?: number; 22 | }): ReactElement => { 23 | const { setValue, ...props } = useContext(BasicContext); 24 | 25 | const handleClickButton = () => { 26 | setValue(itemKey, getIncrementedNumValue(props[itemKey])); 27 | }; 28 | 29 | useEffect(() => { 30 | console.log("mounted", itemKey); 31 | }, [itemKey]); 32 | 33 | logRender("%cBasicItem", logColor(BASIC_COLOR_LIGHT)); 34 | 35 | return ( 36 |
37 | 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/examples/SubscriberDemo/SubscribedItem.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "components/Button"; 2 | import { Style } from "definitions/common-types"; 3 | import React, { ReactElement } from "react"; 4 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 5 | import { getIncrementedCharValue } from "utils/getIncrementedCharValue"; 6 | import { getIncrementedNumValue } from "utils/getIncrementedNumValue"; 7 | import { logColor } from "utils/logColor"; 8 | import { logRender } from "utils/logRender"; 9 | import { SUBSCRIBER_COLOR, SUBSCRIBER_COLOR_LIGHT } from "./colors"; 10 | import { SubscriberContext, SubscriberKey } from "./SubscriberContext"; 11 | 12 | const containerStyle: Style = { 13 | padding: 2, 14 | margin: 2, 15 | display: "inline-block", 16 | flex: 1, 17 | }; 18 | 19 | export const SubscribedItem = ({ itemKey }: { itemKey: SubscriberKey }): ReactElement => { 20 | const [value, setValue] = useSubscribe(SubscriberContext.Context, itemKey); 21 | 22 | const handleClick = () => { 23 | if (typeof value === "number") { 24 | setValue(getIncrementedNumValue(value)); 25 | } else { 26 | setValue(getIncrementedCharValue(value)); 27 | } 28 | }; 29 | 30 | logRender("%cSubscribedItem", logColor(SUBSCRIBER_COLOR_LIGHT)); 31 | 32 | return ( 33 |
34 | 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/react-subscribe-context/getStateChanges.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A key value pair of state changes. `true` indicating that the field was changed. 3 | */ 4 | export interface StateChanges { 5 | [key: string]: boolean; 6 | } 7 | 8 | /** 9 | * Compares the differences between the previous state and what will be changed. 10 | * @param prevState The previous (or current) state that will be changed. 11 | * @param nextState The next state (or partial update of the next state). 12 | * @param path Current path of the changed nested value. i.e. user.name.first 13 | * @returns A key value pair of what values were changed from the prevState. 14 | */ 15 | export const getStateChanges = ( 16 | prevState: TObject, 17 | nextState: TObject, 18 | path = '' 19 | ): StateChanges => { 20 | const keys = Object.keys(nextState) as (keyof TObject & string)[]; 21 | const results: StateChanges = {}; 22 | 23 | keys.forEach((key) => { 24 | if (nextState[key] !== prevState[key]) { 25 | const currentPath = path.length > 0 ? `${path}.${key}` : key; 26 | 27 | if (typeof nextState[key] === 'object' && !Array.isArray(nextState[key])) { 28 | const objDiffs = getStateChanges( 29 | prevState[key] || {}, 30 | nextState[key] || {}, 31 | currentPath 32 | ); 33 | 34 | Object.keys(objDiffs).forEach((diffKey: string) => { 35 | results[diffKey] = objDiffs[diffKey]; 36 | }); 37 | } 38 | results[currentPath] = true; 39 | } 40 | }); 41 | 42 | return results; 43 | }; 44 | -------------------------------------------------------------------------------- /src/examples/BasicDemo/BasicList.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptionsContext } from "components/PerformanceOptions/PerformanceOptionsContext"; 2 | import { Style } from "definitions/common-types"; 3 | import { memo, ReactElement, useContext, useEffect } from "react"; 4 | import { getIncrementedNumValue } from "utils/getIncrementedNumValue"; 5 | import { BasicContext } from "./BasicContext"; 6 | import { BasicItem } from "./BasicItem"; 7 | 8 | const style: Style = { 9 | display: "block", 10 | maxWidth: "100%", 11 | }; 12 | 13 | const MemoizedBasicItem = memo(BasicItem); 14 | 15 | export const BasicList = (): ReactElement => { 16 | const { setState, setValue, ...state } = useContext(BasicContext); 17 | const { 18 | state: { numElements, shouldUseMemo }, 19 | } = useContext(PerformanceOptionsContext); 20 | const keys: (keyof typeof state)[] = []; 21 | 22 | for (let i = 0; i < numElements; i++) { 23 | keys.push(`basic-prop-${i}`); 24 | } 25 | 26 | useEffect(() => { 27 | const newState: typeof state = {}; 28 | 29 | for (let i = 0; i < numElements; i++) { 30 | newState[`basic-prop-${i}`] = getIncrementedNumValue(i - 1); 31 | } 32 | 33 | setState(newState); 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | }, [numElements]); 36 | 37 | return ( 38 |
39 | {keys.map((key) => 40 | shouldUseMemo ? ( 41 | 42 | ) : ( 43 | 44 | ) 45 | )} 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/examples/MemoDemo/ComponentC.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useContext } from "react"; 2 | import { commonStyle } from "utils/common-styles"; 3 | import { ComponentD } from "./ComponentD"; 4 | import { MemoContext } from "./MemoContext"; 5 | 6 | const buttonStyle = { 7 | padding: 16, 8 | minWidth: 100, 9 | width: "fit-content", 10 | cursor: "pointer", 11 | marginBottom: 24, 12 | fontSize: 16, 13 | fontWeight: "bold", 14 | }; 15 | 16 | export const ComponentC = (): ReactElement => { 17 | const { 18 | showRendered, 19 | state: { c }, 20 | setState, 21 | toggleShowRendered, 22 | } = useContext(MemoContext); 23 | 24 | return ( 25 |
29 |
30 | Component C (context) 31 |
32 |
33 | 39 | 45 |
46 | 47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/VanillaMessagingContext.tsx: -------------------------------------------------------------------------------- 1 | import { Conversation } from "examples/MessagingDemo/FakeMessenger"; 2 | import { MessageInfo, User } from "examples/MessagingDemo/types"; 3 | import { createContext, ReactElement, ReactNode, useCallback, useState } from "react"; 4 | 5 | interface SetState { 6 | (nextState: Partial): void | Promise; 7 | (getState: (state: TState) => TState): void | Promise; 8 | } 9 | 10 | interface State { 11 | conversations: Conversation[]; 12 | currentMessages: MessageInfo[]; 13 | currentUser: User; 14 | selectedReceiverName: string; 15 | setState: SetState; 16 | } 17 | 18 | const initialState: State = { 19 | conversations: [] as Conversation[], 20 | currentMessages: [] as MessageInfo[], 21 | currentUser: { 22 | id: "my-user-id", 23 | name: "Akia Vongdara", 24 | } as User, 25 | selectedReceiverName: "", 26 | setState: () => {}, 27 | }; 28 | 29 | export const VanillaMessagingContext = createContext(initialState); 30 | 31 | export const VanillaMessagingProvider = ({ 32 | children, 33 | }: { 34 | children: ReactElement | ReactElement[] | ReactNode; 35 | }) => { 36 | const [state, setState] = useState(initialState); 37 | 38 | const updateState: SetState = useCallback(async (nextState) => { 39 | if (typeof nextState === "function") { 40 | setState(nextState); 41 | } else { 42 | setState((state) => ({ ...state, ...nextState })); 43 | } 44 | }, []); 45 | 46 | return ( 47 | 48 | {children} 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/examples/SubscriberDemo/NumElementsInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "components/Input"; 2 | import { ReactElement, useState } from "react"; 3 | import styled from "styled-components"; 4 | 5 | const StyledInput = styled(Input)` 6 | margin-top: 0; 7 | flex: 1; 8 | `; 9 | 10 | export const NumElementsInput = ({ 11 | currentNumElements, 12 | onClickDisplayNumElements, 13 | }: { 14 | currentNumElements: number; 15 | onClickDisplayNumElements: (nextNumElements: number) => void; 16 | }): ReactElement => { 17 | const [numElementsStr, setNumElementsStr] = useState(currentNumElements.toString()); 18 | const parsedNumElements = Number.parseInt(numElementsStr || "0"); 19 | const numElements = parsedNumElements < 0 ? 0 : parsedNumElements; 20 | 21 | const handleNumElementsChange: React.ChangeEventHandler = (e) => { 22 | setNumElementsStr(e.target.value); 23 | }; 24 | 25 | const handleClickDisplay = () => { 26 | onClickDisplayNumElements(numElements); 27 | }; 28 | 29 | const handleKeyDown: React.KeyboardEventHandler = (e) => { 30 | if (e.key === "Enter") { 31 | onClickDisplayNumElements(numElements); 32 | } 33 | }; 34 | 35 | return ( 36 | <> 37 | 38 |
39 | 46 | 49 |
50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHistory/MessageHistory.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "components/LoadingSpinner"; 2 | import { MessageLine } from "examples/MessagingDemo/MessageHistory/MessageLine"; 3 | import { useMessageHistory } from "examples/MessagingDemo/MessageHistory/useMessageHistory"; 4 | import { createRef, memo, ReactElement, useEffect, useMemo } from "react"; 5 | import styled from "styled-components"; 6 | 7 | const MemoizedMessageLine = memo(MessageLine); 8 | 9 | export const MessageHistory = (): ReactElement => { 10 | const { isLoading, messages, numConversations, senderName } = useMessageHistory(); 11 | const bottomElement = createRef(); 12 | 13 | useEffect(() => { 14 | bottomElement.current?.scrollIntoView({ behavior: "smooth" }); 15 | }); 16 | 17 | useEffect(() => { 18 | bottomElement.current?.scrollIntoView(); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, [isLoading]); 21 | 22 | const messageElements = useMemo( 23 | () => 24 | messages.map((messageInfo) => { 25 | return ( 26 | 31 | ); 32 | }), 33 | [senderName, messages] 34 | ); 35 | 36 | return ( 37 | 38 | {isLoading || numConversations === 0 ? : messageElements} 39 |
40 | 41 | ); 42 | }; 43 | 44 | const StyledMessages = styled.div<{ isLoading: boolean }>` 45 | display: flex; 46 | flex-direction: column; 47 | gap: ${({ isLoading }) => (isLoading ? "0px" : "8px")}; 48 | overflow-y: scroll; 49 | flex: 1; 50 | `; 51 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHistory/VanillaMessageHistory.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "components/LoadingSpinner"; 2 | import { MessageLine } from "examples/MessagingDemo/MessageHistory/MessageLine"; 3 | import { useVanillaMessageHistory } from "examples/MessagingDemo/MessageHistory/useVanillaMessageHistory"; 4 | import { createRef, memo, ReactElement, useEffect, useMemo } from "react"; 5 | import styled from "styled-components"; 6 | 7 | const MemoizedMessageLine = memo(MessageLine); 8 | 9 | export const VanillaMessageHistory = (): ReactElement => { 10 | const { isLoading, messages, numConversations, senderName } = useVanillaMessageHistory(); 11 | const bottomElement = createRef(); 12 | 13 | useEffect(() => { 14 | bottomElement.current?.scrollIntoView({ behavior: "smooth" }); 15 | }); 16 | 17 | useEffect(() => { 18 | bottomElement.current?.scrollIntoView(); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, [isLoading]); 21 | 22 | const messageElements = useMemo( 23 | () => 24 | messages.map((messageInfo) => { 25 | return ( 26 | 31 | ); 32 | }), 33 | [senderName, messages] 34 | ); 35 | 36 | return ( 37 | 38 | {isLoading || numConversations === 0 ? : messageElements} 39 |
40 | 41 | ); 42 | }; 43 | 44 | const StyledMessages = styled.div<{ isLoading: boolean }>` 45 | display: flex; 46 | flex-direction: column; 47 | gap: ${({ isLoading }) => (isLoading ? "0px" : "8px")}; 48 | overflow-y: scroll; 49 | flex: 1; 50 | `; 51 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHistory/useVanillaMessageHistory.ts: -------------------------------------------------------------------------------- 1 | import { FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 2 | import { MessageInfo } from "examples/MessagingDemo/types"; 3 | import { useSubscribeMessageSocket } from "examples/MessagingDemo/useSubscribeMessageSocket"; 4 | import { VanillaMessagingContext } from "examples/MessagingDemo/VanillaMessagingContext"; 5 | import { useCallback, useContext, useEffect, useState } from "react"; 6 | 7 | export const useVanillaMessageHistory = () => { 8 | const { currentMessages, selectedReceiverName, conversations, currentUser, setState } = 9 | useContext(VanillaMessagingContext); 10 | const [isLoading, setIsLoading] = useState(true); 11 | const numConversations = conversations.length; 12 | const senderName = currentUser.name; 13 | 14 | const handleIncomingMessage = useCallback( 15 | (messageInfo: MessageInfo) => { 16 | if (messageInfo.senderName === selectedReceiverName) { 17 | setState((prevState) => { 18 | return { 19 | ...prevState, 20 | currentMessages: [...prevState.currentMessages, messageInfo], 21 | }; 22 | }); 23 | } 24 | }, 25 | [setState, selectedReceiverName] 26 | ); 27 | 28 | useSubscribeMessageSocket("message-from-friend", handleIncomingMessage); 29 | 30 | useEffect(() => { 31 | const fetchMessages = async () => { 32 | setIsLoading(true); 33 | 34 | const messages = await FakeMessenger.getMessages(selectedReceiverName); 35 | 36 | setState({ currentMessages: messages }); 37 | setIsLoading(false); 38 | }; 39 | 40 | if (numConversations > 0) { 41 | fetchMessages(); 42 | } 43 | }, [numConversations, selectedReceiverName, setState]); 44 | 45 | return { isLoading, messages: currentMessages, numConversations, senderName }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/react-subscribe-context/createProxyHandler.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { EventKey } from './context-control-types'; 3 | 4 | export interface SubscribedCache { 5 | [event: EventKey]: boolean | undefined; 6 | } 7 | 8 | /** 9 | * Creates a proxy handler to intercept an object so that it can store information on what fields were accessed. It's used to know what fields to subscribe to. 10 | * 11 | * @param subscriptionRef A reference to where to store what fields where subscribed to 12 | * @param rerender A function to rerender useSubscribe so that it can subscribed to the accessed field 13 | * @param baseKey A prefix of the event key name. Used in situations where you want to use the name of the variable as the prefix so that it matches with the state updates. 14 | * 15 | * Ex. Proxy of `const user = { name: { first: "" } } will have event keys like name.first. With a baseKey of 'user', you'll get back user.name.first. 16 | * @returns Proxy handler 17 | */ 18 | export const createProxyHandler = ( 19 | subscriptionRef: React.MutableRefObject, 20 | rerender: () => void, 21 | baseKey = '' 22 | ) => { 23 | return { 24 | get: (obj: TState, key: keyof TState, root: object, keys: (keyof TState)[]) => { 25 | const parentPath = `${baseKey ? `${baseKey}.` : ''}${keys.join('.')}`; 26 | const path = `${parentPath}${keys.length > 0 ? `.${key}` : key}`; 27 | const event: EventKey = `update-${path}`; 28 | 29 | if (subscriptionRef.current[event] === undefined) { 30 | const parentEventToRemove: EventKey = `update-${parentPath}`; 31 | 32 | subscriptionRef.current[event] = true; 33 | 34 | if (subscriptionRef.current[parentEventToRemove] === true) { 35 | subscriptionRef.current[parentEventToRemove] = false; 36 | } 37 | 38 | rerender(); 39 | } 40 | 41 | return obj[key]; 42 | }, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageHistory/useMessageHistory.ts: -------------------------------------------------------------------------------- 1 | import { FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 2 | import { MessagingSubscriberContext } from "examples/MessagingDemo/MessagingSubscriberContext"; 3 | import { MessageInfo } from "examples/MessagingDemo/types"; 4 | import { useSubscribeMessageSocket } from "examples/MessagingDemo/useSubscribeMessageSocket"; 5 | import { useCallback, useContext, useEffect, useState } from "react"; 6 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 7 | 8 | export const useMessageHistory = () => { 9 | const [state, setState] = useSubscribe(MessagingSubscriberContext); 10 | const [isLoading, setIsLoading] = useState(true); 11 | const { getValue } = useContext(MessagingSubscriberContext); 12 | const { currentMessages, selectedReceiverName } = state; 13 | const numConversations = getValue("conversations").length; 14 | const senderName = state.currentUser.name; 15 | 16 | const handleIncomingMessage = useCallback( 17 | (messageInfo: MessageInfo) => { 18 | if (messageInfo.senderName === selectedReceiverName) { 19 | setState((prevState) => { 20 | return { currentMessages: [...prevState.currentMessages, messageInfo] }; 21 | }); 22 | } 23 | }, 24 | [setState, selectedReceiverName] 25 | ); 26 | 27 | useSubscribeMessageSocket("message-from-friend", handleIncomingMessage); 28 | 29 | useEffect(() => { 30 | const fetchMessages = async () => { 31 | setIsLoading(true); 32 | 33 | const messages = await FakeMessenger.getMessages(getValue("selectedReceiverName")); 34 | 35 | setState({ currentMessages: messages }); 36 | setIsLoading(false); 37 | }; 38 | 39 | if (getValue("conversations").length > 0) { 40 | fetchMessages(); 41 | } 42 | }, [numConversations, selectedReceiverName, setState, getValue]); 43 | 44 | return { isLoading, messages: currentMessages, numConversations, senderName }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/examples/AdvancedDemo/AdvancedList.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptionsContext } from "components/PerformanceOptions/PerformanceOptionsContext"; 2 | import { Style } from "definitions/common-types"; 3 | import { memo, ReactElement, useContext, useEffect } from "react"; 4 | import { getIncrementedNumValue } from "utils/getIncrementedNumValue"; 5 | import { AdvancedContext } from "./AdvancedContext"; 6 | import { AdvancedItem } from "./AdvancedItem"; 7 | 8 | const style: Style = { 9 | display: "block", 10 | maxWidth: "100%", 11 | }; 12 | 13 | const MemoizedAdvancedItem = memo(AdvancedItem); 14 | 15 | export const AdvancedList = (): ReactElement => { 16 | const { setState, setValue, ...state } = useContext(AdvancedContext); 17 | const { 18 | state: { numElements, shouldUseMemo }, 19 | } = useContext(PerformanceOptionsContext); 20 | const keys: (keyof typeof state)[] = []; 21 | 22 | for (let i = 0; i < numElements; i++) { 23 | keys.push(`advanced-prop-${i}`); 24 | } 25 | 26 | const handleClickButton: React.MouseEventHandler = (e) => { 27 | const target = e.target as unknown as HTMLButtonElement; 28 | 29 | if (target.dataset["key"]) { 30 | const key = target.dataset["key"] as keyof typeof state; 31 | 32 | setState({ 33 | [key]: getIncrementedNumValue(state[key]), 34 | }); 35 | } 36 | }; 37 | 38 | useEffect(() => { 39 | const newState: typeof state = {}; 40 | 41 | for (let i = 0; i < numElements; i++) { 42 | newState[`advanced-prop-${i}`] = getIncrementedNumValue(i - 1); 43 | } 44 | 45 | setState(newState); 46 | // eslint-disable-next-line react-hooks/exhaustive-deps 47 | }, [numElements]); 48 | 49 | return ( 50 |
51 | {keys.map((key) => 52 | shouldUseMemo ? ( 53 | 54 | ) : ( 55 | 56 | ) 57 | )} 58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/Conversations/Conversations.tsx: -------------------------------------------------------------------------------- 1 | import { ContactListItem } from "examples/MessagingDemo/Conversations/ContactListItem"; 2 | import { useConversations } from "examples/MessagingDemo/Conversations/useConversations"; 3 | import { memo, ReactElement } from "react"; 4 | import styled from "styled-components"; 5 | 6 | const MemoizedContactListItem = memo(ContactListItem); 7 | 8 | export const Conversations = (): ReactElement => { 9 | const { conversations, onChangeSearch, onClickConversation, search, selectedReceiverName } = 10 | useConversations(); 11 | 12 | return ( 13 | 14 | 15 | Active Conversations ({conversations.length}) 16 | 17 | {conversations.map(({ name, recentMessage, numUnreadMessages }) => { 18 | return ( 19 | 28 | ); 29 | })} 30 | 31 | 32 | ); 33 | }; 34 | 35 | const StyledInput = styled.input` 36 | border-radius: 4px; 37 | border: 1px solid #dcdde0; 38 | padding: 8px 12px; 39 | `; 40 | 41 | const StyledContainer = styled.div` 42 | display: flex; 43 | flex-direction: column; 44 | height: 100%; 45 | `; 46 | 47 | const StyledConversations = styled.div` 48 | display: flex; 49 | flex-direction: column; 50 | gap: 1px; 51 | flex: 1; 52 | overflow-y: scroll; 53 | `; 54 | 55 | const StyledHeader = styled.div` 56 | text-align: left; 57 | font-weight: bold; 58 | padding: 12px 0; 59 | `; 60 | -------------------------------------------------------------------------------- /src/examples/SpiderManDemo/NameComponent.tsx: -------------------------------------------------------------------------------- 1 | // NameComponent.tsx 2 | import { FirstNameComponent } from "examples/SpiderManDemo/FirstNameComponent"; 3 | import { LastNameComponent } from "examples/SpiderManDemo/LastNameComponent"; 4 | import { ReactElement } from "react"; 5 | import { useSubscribe } from "react-subscribe-context"; 6 | import styled from "styled-components"; 7 | import { SpiderManContext } from "./SpiderManContext"; 8 | 9 | type Name = { first: string; last: string }; 10 | 11 | const spiderManNames: Name[] = [ 12 | { first: "Peter", last: "Parker" }, 13 | { first: "Peter", last: "Porker" }, 14 | { first: "Peni", last: "Parker" }, 15 | { first: "Miles", last: "Morales" }, 16 | ]; 17 | 18 | const getRandomSpiderManName = (currentName: Name) => { 19 | let randomName: Name = spiderManNames[0]; 20 | 21 | do { 22 | randomName = spiderManNames[Math.floor(Math.random() * spiderManNames.length)]; 23 | } while (currentName.first === randomName.first && currentName.last === randomName.last); 24 | 25 | return randomName; 26 | }; 27 | 28 | export const NameComponent = (): ReactElement => { 29 | const [, setContextState] = useSubscribe(SpiderManContext); 30 | 31 | const handleClickRandomizeName = () => { 32 | setContextState((prevState) => { 33 | let { 34 | user: { name }, 35 | } = prevState; 36 | 37 | const randomSpiderManName = getRandomSpiderManName(name); 38 | 39 | return { 40 | ...prevState, 41 | user: { 42 | ...prevState.user, 43 | name: randomSpiderManName, 44 | }, 45 | }; 46 | }); 47 | }; 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | const StyledName = styled("div")` 59 | display: flex; 60 | gap: 8px; 61 | font-size: 24px; 62 | color: white; 63 | align-items: center; 64 | 65 | button { 66 | font-size: 16px; 67 | font-weight: bold; 68 | padding: 12px; 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/Conversations/VanillaConversations.tsx: -------------------------------------------------------------------------------- 1 | import { ContactListItem } from "examples/MessagingDemo/Conversations/ContactListItem"; 2 | import { useVanillaConversations } from "examples/MessagingDemo/Conversations/useVanillaConversations"; 3 | import { memo, ReactElement } from "react"; 4 | import styled from "styled-components"; 5 | 6 | const MemoizedContactListItem = memo(ContactListItem); 7 | 8 | export const VanillaConversations = (): ReactElement => { 9 | const { conversations, onChangeSearch, onClickConversation, search, selectedReceiverName } = 10 | useVanillaConversations(); 11 | 12 | return ( 13 | 14 | 15 | Active Conversations ({conversations.length}) 16 | 17 | {conversations.map(({ name, recentMessage, numUnreadMessages }) => { 18 | return ( 19 | 28 | ); 29 | })} 30 | 31 | 32 | ); 33 | }; 34 | 35 | const StyledInput = styled.input` 36 | border-radius: 4px; 37 | border: 1px solid #dcdde0; 38 | padding: 8px 12px; 39 | `; 40 | 41 | const StyledContainer = styled.div` 42 | display: flex; 43 | flex-direction: column; 44 | height: 100%; 45 | `; 46 | 47 | const StyledConversations = styled.div` 48 | display: flex; 49 | flex-direction: column; 50 | gap: 1px; 51 | flex: 1; 52 | overflow-y: scroll; 53 | `; 54 | 55 | const StyledHeader = styled.div` 56 | text-align: left; 57 | font-weight: bold; 58 | padding: 12px 0; 59 | `; 60 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 20 | 21 | 30 | React App 31 | 36 | 37 | 38 | 39 |
40 | 50 | 51 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessageInput.tsx: -------------------------------------------------------------------------------- 1 | import { EVT_MESSAGE_TO_FRIEND } from "constants/event-names"; 2 | import { FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 3 | import { MessagingSubscriberContext } from "examples/MessagingDemo/MessagingSubscriberContext"; 4 | import { createRef, KeyboardEventHandler, ReactElement, useContext } from "react"; 5 | import styled from "styled-components"; 6 | 7 | export const MessageInput = (): ReactElement => { 8 | const inputRef = createRef(); 9 | const { getValue, setValue } = useContext(MessagingSubscriberContext); 10 | 11 | const handleClickSend = async () => { 12 | if (inputRef.current && inputRef.current.value) { 13 | const messageInfo = await FakeMessenger.sendMessage({ 14 | senderName: getValue("currentUser").name, 15 | receiverName: getValue("selectedReceiverName"), 16 | text: inputRef.current.value, 17 | }); 18 | 19 | const nextVal = [...getValue("currentMessages"), messageInfo]; 20 | 21 | setValue("currentMessages", nextVal); 22 | 23 | FakeMessenger.getEmitter().emit(EVT_MESSAGE_TO_FRIEND, messageInfo); 24 | inputRef.current.value = ""; 25 | } 26 | }; 27 | 28 | const handleKeyPress: KeyboardEventHandler = (e) => { 29 | if (e.key === "Enter") { 30 | handleClickSend(); 31 | } 32 | }; 33 | 34 | return ( 35 | 36 | 41 | Send 42 | 43 | ); 44 | }; 45 | 46 | const StyledMessageInput = styled.div` 47 | background-color: white; 48 | border-radius: 8px; 49 | box-shadow: 0 0px 12px -6px #d4d4d4; 50 | display: flex; 51 | margin-top: 16px; 52 | padding: 12px; 53 | gap: 12px; 54 | `; 55 | 56 | const StyledInput = styled.input` 57 | border-radius: 4px; 58 | border: 1px solid #dcdde0; 59 | flex: 1; 60 | padding: 8px 12px; 61 | `; 62 | 63 | const StyledButton = styled.button` 64 | background-color: #004dfc; 65 | border: 0; 66 | border-radius: 6px; 67 | color: white; 68 | font-size: 12px; 69 | font-weight: 600; 70 | padding: 8px 12px; 71 | `; 72 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/VanillaMessageInput.tsx: -------------------------------------------------------------------------------- 1 | import { EVT_MESSAGE_TO_FRIEND } from "constants/event-names"; 2 | import { FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 3 | import { VanillaMessagingContext } from "examples/MessagingDemo/VanillaMessagingContext"; 4 | import { createRef, KeyboardEventHandler, ReactElement, useContext } from "react"; 5 | import styled from "styled-components"; 6 | 7 | export const VanillaMessageInput = (): ReactElement => { 8 | const inputRef = createRef(); 9 | const { currentUser, selectedReceiverName, currentMessages, setState } = 10 | useContext(VanillaMessagingContext); 11 | 12 | const handleClickSend = async () => { 13 | if (inputRef.current && inputRef.current.value) { 14 | const messageInfo = await FakeMessenger.sendMessage({ 15 | senderName: currentUser.name, 16 | receiverName: selectedReceiverName, 17 | text: inputRef.current.value, 18 | }); 19 | 20 | inputRef.current.value = ""; 21 | 22 | const nextMessages = [...currentMessages, messageInfo]; 23 | 24 | setState((prevState) => { 25 | return { ...prevState, currentMessages: nextMessages }; 26 | }); 27 | 28 | FakeMessenger.getEmitter().emit(EVT_MESSAGE_TO_FRIEND, messageInfo); 29 | } 30 | }; 31 | 32 | const handleKeyPress: KeyboardEventHandler = (e) => { 33 | if (e.key === "Enter") { 34 | handleClickSend(); 35 | } 36 | }; 37 | 38 | return ( 39 | 40 | 45 | Send 46 | 47 | ); 48 | }; 49 | 50 | const StyledMessageInput = styled.div` 51 | background-color: white; 52 | border-radius: 8px; 53 | box-shadow: 0 0px 12px -6px #d4d4d4; 54 | display: flex; 55 | margin-top: 16px; 56 | padding: 12px; 57 | gap: 12px; 58 | `; 59 | 60 | const StyledInput = styled.input` 61 | border-radius: 4px; 62 | border: 1px solid #dcdde0; 63 | flex: 1; 64 | padding: 8px 12px; 65 | `; 66 | 67 | const StyledButton = styled.button` 68 | background-color: #004dfc; 69 | border: 0; 70 | border-radius: 6px; 71 | color: white; 72 | font-size: 12px; 73 | font-weight: 600; 74 | padding: 8px 12px; 75 | `; 76 | -------------------------------------------------------------------------------- /src/react-subscribe-context/getStateChanges.test.ts: -------------------------------------------------------------------------------- 1 | import { getStateChanges } from "./getStateChanges"; 2 | 3 | describe("getStateChanges", () => { 4 | it("should get all state changes for first level fields", () => { 5 | const prevState = { 6 | str: "string", 7 | num: 0, 8 | bool: true, 9 | arr: ["x"], 10 | obj: {}, 11 | }; 12 | const nextState: typeof prevState = { 13 | str: "new string", 14 | num: 7, 15 | bool: false, 16 | arr: ["x"], 17 | obj: {}, 18 | }; 19 | const stateChanges = getStateChanges(prevState, nextState); 20 | 21 | expect(Object.keys(stateChanges)).toMatchObject(["str", "num", "bool", "arr", "obj"]); 22 | }); 23 | 24 | it("should get no state changes for values that are the same", () => { 25 | const prevState = { 26 | str: "string", 27 | num: 0, 28 | bool: true, 29 | arr: ["x"], 30 | obj: {}, 31 | }; 32 | const nextState: typeof prevState = { ...prevState }; 33 | const stateChanges = getStateChanges(prevState, nextState); 34 | 35 | expect(Object.keys(stateChanges)).toMatchObject([]); 36 | }); 37 | 38 | it("should get all state changes for nested objects", () => { 39 | const prevState = { 40 | user: { 41 | name: { 42 | first: "first", 43 | last: "last", 44 | }, 45 | }, 46 | }; 47 | const nextState: typeof prevState = { 48 | user: { 49 | name: { 50 | first: "Peter", 51 | last: "Parker", 52 | }, 53 | }, 54 | }; 55 | const stateChanges = getStateChanges(prevState, nextState); 56 | 57 | expect(Object.keys(stateChanges)).toMatchObject([ 58 | "user.name.first", 59 | "user.name.last", 60 | "user.name", 61 | "user", 62 | ]); 63 | }); 64 | 65 | it("should get all state changes for values becoming undefined and vice versa", () => { 66 | const prevState = { 67 | str: "string" as string | undefined, 68 | obj: {} as object | undefined, 69 | }; 70 | const nextState: typeof prevState = { 71 | str: undefined, 72 | obj: undefined, 73 | }; 74 | const stateChanges = getStateChanges(prevState, nextState); 75 | 76 | expect(Object.keys(stateChanges)).toMatchObject(["str", "obj"]); 77 | 78 | const stateChangesReversed = getStateChanges(nextState, prevState); 79 | 80 | expect(Object.keys(stateChangesReversed)).toMatchObject(["str", "obj"]); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/react-subscribe-context/useSubscribeProvider.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { ActionsCreator, ContextControl } from './context-control-types'; 3 | import { getStateChanges } from './getStateChanges'; 4 | import { getUpdateEventName } from './getUpdateEventName'; 5 | 6 | /** 7 | * Creates a state management object to handle the given initial state. 8 | * @param initialControl Initial control methods and emitter. 9 | * @param initialState Initial state of the context. 10 | * @param createActions Function to create reusable actions. 11 | * @returns The control object used for manage the context. 12 | */ 13 | export const useSubscribeProvider = < 14 | TState, 15 | TActions extends object, 16 | TControlState extends ContextControl 17 | >( 18 | initialControl: TControlState, 19 | initialState: TState, 20 | createActions?: ActionsCreator 21 | ) => { 22 | const control = useRef({ ...initialControl }); 23 | const contextState = useRef({ ...initialState }); 24 | 25 | control.current.setState = (getNextState) => { 26 | let nextState = getNextState; 27 | 28 | if (nextState instanceof Function) { 29 | nextState = nextState(control.current.getState()); 30 | } 31 | 32 | const stateChanges = getStateChanges(contextState.current, nextState); 33 | const changedFields = Object.keys(stateChanges); 34 | 35 | contextState.current = { ...contextState.current, ...nextState }; 36 | 37 | if (changedFields.length > 0) { 38 | control.current.emitter.emit('update-state', contextState.current); 39 | } 40 | 41 | changedFields.forEach((key) => { 42 | control.current.emitter.emit(getUpdateEventName(key), nextState); 43 | }); 44 | }; 45 | 46 | control.current.setValue = (key, getNextValue) => { 47 | let nextValue = getNextValue; 48 | 49 | if (nextValue instanceof Function) { 50 | nextValue = nextValue(control.current.getValue(key), control.current.getState()); 51 | } 52 | 53 | const partialUpdatedState = { [key]: nextValue } as unknown as Partial; 54 | const stateChanges = getStateChanges(contextState.current, partialUpdatedState); 55 | 56 | contextState.current = { ...contextState.current, [key]: nextValue }; 57 | 58 | Object.keys(stateChanges).forEach((key) => { 59 | control.current.emitter.emit(getUpdateEventName(key), partialUpdatedState); 60 | }); 61 | }; 62 | 63 | control.current.getValue = (fieldName) => contextState.current[fieldName]; 64 | 65 | control.current.getState = () => contextState.current; 66 | 67 | // @ts-ignore 68 | control.current.actions = createActions ? createActions(control.current) : {}; 69 | 70 | return control; 71 | }; 72 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/Conversations/ContactListItem.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react"; 2 | import styled from "styled-components"; 3 | 4 | interface Props { 5 | className?: string; 6 | handleClickConversation: MouseEventHandler; 7 | isRecentMessageFromUser: boolean; 8 | name: string; 9 | numUnreadMessages?: number; 10 | recentText?: string; 11 | } 12 | 13 | export const ContactListItem = ({ 14 | className, 15 | handleClickConversation, 16 | isRecentMessageFromUser, 17 | name, 18 | numUnreadMessages = 0, 19 | recentText, 20 | }: Props) => { 21 | const displayName = `${isRecentMessageFromUser ? "You: " : ""}${recentText}`; 22 | 23 | return ( 24 | 30 | 31 | {name} 32 | {recentText && {displayName}} 33 | 34 | {numUnreadMessages > 0 && ( 35 | 36 | {numUnreadMessages} 37 | 38 | )} 39 | 40 | ); 41 | }; 42 | 43 | const StyledBadgeContainer = styled.div` 44 | align-items: center; 45 | justify-content: flex-end; 46 | display: flex; 47 | `; 48 | 49 | const StyledBadge = styled.div` 50 | background-color: #ff5757; 51 | width: 24px; 52 | height: 24px; 53 | border-radius: 100%; 54 | display: flex; 55 | align-items: center; 56 | justify-content: center; 57 | font-size: 12px; 58 | font-weight: 600; 59 | color: white; 60 | `; 61 | 62 | const StyledContentContainer = styled.div` 63 | flex: 1; 64 | flex-direction: column; 65 | display: flex; 66 | width: 1%; 67 | `; 68 | 69 | const StyledRecentName = styled.div` 70 | text-overflow: ellipsis; 71 | overflow: hidden; 72 | white-space: nowrap; 73 | font-size: 12px; 74 | color: slategray; 75 | margin-top: 4px; 76 | `; 77 | 78 | const StyledContactName = styled.div` 79 | text-overflow: ellipsis; 80 | overflow: hidden; 81 | white-space: nowrap; 82 | `; 83 | 84 | const StyledContact = styled.div` 85 | padding: 16px 12px; 86 | border-radius: 12px; 87 | text-align: left; 88 | cursor: pointer; 89 | display: flex; 90 | 91 | &:hover { 92 | background-color: #f3f6fb; 93 | } 94 | 95 | &.selected { 96 | background-color: #f3f6fb; 97 | 98 | ${StyledContactName} { 99 | font-weight: bold; 100 | } 101 | } 102 | `; 103 | -------------------------------------------------------------------------------- /src/react-subscribe-context/createSubscriberContext.tsx: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { Context, createContext, ReactElement, ReactNode } from 'react'; 3 | import { ActionsCreator, BaseContextControl, ContextControl } from './context-control-types'; 4 | import { useSubscribeProvider } from './useSubscribeProvider'; 5 | 6 | interface CreateControlContextConfig { 7 | /** 8 | * Initial state that the context will be based off of. 9 | */ 10 | initialState: TState; 11 | /** 12 | * Function used to create reusable actions that updates the state. 13 | */ 14 | createActions?: ActionsCreator; 15 | } 16 | 17 | type ControlProvider = (props: { 18 | children: ReactElement | ReactElement[] | ReactNode; 19 | }) => JSX.Element; 20 | 21 | type ContextReturn = [ 22 | Context>, 23 | ControlProvider 24 | ] & { 25 | Context: Context>; 26 | Provider: ControlProvider; 27 | }; 28 | 29 | /** 30 | * Creates a context and provider to help control. 31 | * @param config Configuration options of the control context. 32 | * @returns A tuple of a control context and provider. 33 | */ 34 | export const createSubscriberContext = ({ 35 | initialState, 36 | createActions, 37 | }: CreateControlContextConfig): ContextReturn => { 38 | const baseControl: BaseContextControl = { 39 | emitter: new EventEmitter(), 40 | getState: () => { 41 | console.error('Did you forget to use your control provider?'); 42 | return initialState; 43 | }, 44 | getValue: (fieldName) => { 45 | console.error('Did you forget to use your control provider?'); 46 | return initialState[fieldName]; 47 | }, 48 | setState: () => { 49 | console.error('Did you forget to use your control provider?'); 50 | }, 51 | setValue: () => { 52 | console.error('Did you forget to use your control provider?'); 53 | }, 54 | }; 55 | 56 | const initialControl: ContextControl = { 57 | ...baseControl, 58 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 59 | // @ts-ignore 60 | actions: {}, 61 | }; 62 | const Context = createContext>(initialControl); 63 | 64 | const Provider = ({ children }: { children: ReactElement | ReactElement[] }) => { 65 | const control = useSubscribeProvider>( 66 | initialControl, 67 | initialState, 68 | createActions 69 | ); 70 | 71 | return {children}; 72 | }; 73 | 74 | const result = [Context, Provider] as any; 75 | 76 | result.Context = Context; 77 | result.Provider = Provider; 78 | 79 | return result as ContextReturn; 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/PerformanceOptions/PerformanceOptions.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "components/Input"; 2 | import { ReactElement, useContext, useState } from "react"; 3 | import styled from "styled-components"; 4 | import { logColor } from "utils/logColor"; 5 | import { logRender } from "utils/logRender"; 6 | import { NumElementsInput } from "../../examples/SubscriberDemo/NumElementsInput"; 7 | import { PERFORMANCE_OPTIONS_COLOR } from "./colors"; 8 | import { PerformanceOptionsContext } from "./PerformanceOptionsContext"; 9 | 10 | const StyledInputContainer = styled("div")` 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | flex-direction: row; 15 | gap: 16px; 16 | margin-bottom: 32px; 17 | `; 18 | 19 | const StyledFormGroup = styled("div")` 20 | display: flex; 21 | flex-direction: column; 22 | color: white; 23 | width: 300px; 24 | 25 | label { 26 | text-align: left; 27 | font-weight: 600; 28 | margin-bottom: 8px; 29 | } 30 | `; 31 | 32 | const StyledButton = styled.button` 33 | padding: 12px; 34 | cursor: pointer; 35 | 36 | &.on { 37 | font-weight: bold; 38 | } 39 | `; 40 | 41 | const StyledContainer = styled.div` 42 | width: fit-content; 43 | margin: auto; 44 | `; 45 | 46 | export const PerformanceOptions = (): ReactElement => { 47 | const { state, setState } = useContext(PerformanceOptionsContext); 48 | const [inputValue, setInputValue] = useState(""); 49 | const currentNumElements = state.numElements; 50 | 51 | const handleInputChange: React.ChangeEventHandler = (e) => { 52 | setInputValue(e.target.value); 53 | setState(state); 54 | }; 55 | 56 | const onClickDisplayNumElements = (nextNumElements: number) => { 57 | setState({ numElements: nextNumElements }); 58 | }; 59 | 60 | const handleToggleUseMemo = () => { 61 | setState({ shouldUseMemo: !state.shouldUseMemo }); 62 | }; 63 | 64 | logRender("%cPerformanceOptions", logColor(PERFORMANCE_OPTIONS_COLOR)); 65 | 66 | return ( 67 | 68 | 69 | 70 | 71 | 76 | 77 | 78 | 82 | 83 | 84 | 85 | 89 | Memo {state.shouldUseMemo ? "ON" : "OFF"} 90 | 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/react-subscribe-context/createProxyHandler.test.ts: -------------------------------------------------------------------------------- 1 | import deepProxy from "deep-proxy-polyfill"; 2 | import { createProxyHandler, SubscribedCache } from "./createProxyHandler"; 3 | 4 | describe("createProxyHandler", () => { 5 | it("should mark subscriptions for accessed values of proxy obj", () => { 6 | const state = { 7 | str: "string", 8 | num: 10, 9 | }; 10 | const mockRerenderer = jest.fn(); 11 | const subscriptionRef: React.MutableRefObject = { current: {} }; 12 | const proxyHandler = createProxyHandler(subscriptionRef, mockRerenderer); 13 | 14 | const objProxy = deepProxy(state, proxyHandler); 15 | 16 | (() => objProxy.num)(); 17 | 18 | expect(subscriptionRef.current).toMatchObject({ "update-num": true }); 19 | expect(mockRerenderer).toBeCalledTimes(1); 20 | 21 | (() => objProxy.str)(); 22 | 23 | expect(subscriptionRef.current).toMatchObject({ "update-str": true, "update-num": true }); 24 | expect(mockRerenderer).toBeCalledTimes(2); 25 | }); 26 | 27 | it("should mark subscriptions for specifically accessed nested values of proxy obj", () => { 28 | const state = { 29 | user: { 30 | name: { 31 | first: "first", 32 | last: "last", 33 | }, 34 | }, 35 | }; 36 | const mockRerenderer = jest.fn(); 37 | const subscriptionRef: React.MutableRefObject = { current: {} }; 38 | const proxyHandler = createProxyHandler(subscriptionRef, mockRerenderer); 39 | 40 | const objProxy = deepProxy(state, proxyHandler); 41 | 42 | (() => objProxy.user)(); 43 | 44 | expect(subscriptionRef.current).toMatchObject({ "update-user": true }); 45 | expect(mockRerenderer).toBeCalledTimes(1); 46 | 47 | (() => objProxy.user.name.first)(); 48 | 49 | expect(subscriptionRef.current).toMatchObject({ 50 | "update-user": false, 51 | "update-user.name": false, 52 | "update-user.name.first": true, 53 | }); 54 | // 2 more renders for accessing name, then name.first 55 | expect(mockRerenderer).toBeCalledTimes(3); 56 | }); 57 | 58 | it("should mark subscriptions with a prefix baseKey", () => { 59 | const state = { 60 | user: { 61 | name: { 62 | first: "first", 63 | last: "last", 64 | }, 65 | }, 66 | }; 67 | const mockRerenderer = jest.fn(); 68 | const subscriptionRef: React.MutableRefObject = { current: {} }; 69 | const proxyHandler = createProxyHandler( 70 | subscriptionRef, 71 | mockRerenderer, 72 | "state" 73 | ); 74 | 75 | const objProxy = deepProxy(state, proxyHandler); 76 | 77 | (() => objProxy.user.name.last)(); 78 | 79 | expect(subscriptionRef.current).toMatchObject({ 80 | "update-state.user": false, 81 | "update-state.user.name": false, 82 | "update-state.user.name.last": true, 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-subscribe-context", 3 | "version": "0.6.3", 4 | "private": false, 5 | "author": "Akia Vongdara", 6 | "description": "A simple and consistent way of state management to avoid prop drilling, and is optimized for component rerenders.", 7 | "license": "MIT", 8 | "source": "src/react-subscribe-context/index.ts", 9 | "main": "dist/bundle", 10 | "typings": "dist/typings", 11 | "files": [ 12 | "dist/*" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/vongdarakia/react-subscribe-context" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject", 23 | "build-lib": "rm -rf dist && npx webpack", 24 | "generate-package-for-testing": "npm run build-lib && npm pack" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "ie 11", 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "dependencies": { 46 | "deep-proxy-polyfill": "^1.0.4" 47 | }, 48 | "peerDependencies": { 49 | "react": ">=16.8.0" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.17.8", 53 | "@babel/preset-env": "^7.16.11", 54 | "@babel/preset-react": "^7.16.7", 55 | "@testing-library/jest-dom": "^5.16.2", 56 | "@testing-library/react": "^12.1.4", 57 | "@testing-library/react-hooks": "^7.0.2", 58 | "@testing-library/user-event": "^13.5.0", 59 | "@types/events": "^3.0.0", 60 | "@types/jest": "^27.4.0", 61 | "@types/node": "^16.11.25", 62 | "@types/react": "^17.0.39", 63 | "@types/react-dom": "^17.0.11", 64 | "@types/styled-components": "^5.1.23", 65 | "axios": "^0.26.0", 66 | "babel-loader": "^8.2.4", 67 | "deep-proxy-polyfill": "^1.0.4", 68 | "react-app-polyfill": "^3.0.0", 69 | "react-dom": "^17.0.2", 70 | "react-scripts": "^5.0.0", 71 | "react-tracked": "^1.7.6", 72 | "scheduler": "^0.20.2", 73 | "styled-components": "^5.3.3", 74 | "ts-loader": "^9.2.8", 75 | "typescript": "^4.5.5", 76 | "web-vitals": "^2.1.4", 77 | "webpack-cli": "^4.9.2", 78 | "webpack-node-externals": "^3.0.0" 79 | }, 80 | "alias": { 81 | "components/*": "./src/components/$1", 82 | "constants/*": "./src/constants/$1", 83 | "definitions/*": "./src/types/$1", 84 | "examples/*": "./src/examples/$1", 85 | "react-subscribe-context/*": "./src/react-subscribe-context/$1", 86 | "utils/*": "./src/utils/$1" 87 | }, 88 | "jest": { 89 | "collectCoverageFrom": [ 90 | "./src/react-subscribe-context/*.{ts,tsx}" 91 | ], 92 | "coveragePathIgnorePatterns": [ 93 | "/src/react-subscribe-context/index.ts", 94 | "/src/react-subscribe-context/context-control-types.ts" 95 | ] 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/react-subscribe-context/createSubscriberContext.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react-hooks"; 2 | import { useContext } from "react"; 3 | import { BaseContextControl } from "./context-control-types"; 4 | import { createSubscriberContext } from "./createSubscriberContext"; 5 | 6 | describe("createSubscriberContext", () => { 7 | const initialState = { 8 | user: { 9 | name: { 10 | first: "Peter", 11 | last: "Parker", 12 | }, 13 | }, 14 | age: 42, 15 | }; 16 | 17 | it("should create a subscriber context", async () => { 18 | const { Context, Provider } = createSubscriberContext({ initialState }); 19 | const { result } = renderHook(() => useContext(Context), { 20 | wrapper: ({ children }) => {children}, 21 | }); 22 | 23 | expect(result.current.getState()).toMatchObject(initialState); 24 | expect(result.current.getValue("age")).toBe(initialState.age); 25 | 26 | result.current.setState({ age: 24 }); 27 | 28 | expect(result.current.getValue("age")).toBe(24); 29 | 30 | result.current.setValue("user", (prevUser) => ({ 31 | ...prevUser, 32 | name: { first: "Miles", last: "Morales" }, 33 | })); 34 | expect(result.current.getState()).toMatchObject({ 35 | user: { 36 | name: { 37 | first: "Miles", 38 | last: "Morales", 39 | }, 40 | }, 41 | age: 24, 42 | }); 43 | expect(result.current.actions).toMatchObject({}); 44 | }); 45 | it("should create a subscriber context given an actions creator", async () => { 46 | const createActions = (baseContextControl: BaseContextControl) => { 47 | return { 48 | getAge: () => { 49 | return baseContextControl.getValue("age"); 50 | }, 51 | }; 52 | }; 53 | 54 | const { Context, Provider } = createSubscriberContext({ initialState, createActions }); 55 | const { result } = renderHook(() => useContext(Context), { 56 | wrapper: ({ children }) => {children}, 57 | }); 58 | 59 | expect(result.current.actions.getAge()).toBe(initialState.age); 60 | }); 61 | it("should use default controls when no provider is given", async () => { 62 | jest.spyOn(global.console, "error").mockImplementation(() => {}); 63 | const createActions = (baseContextControl: BaseContextControl) => { 64 | return { 65 | getAge: () => { 66 | return baseContextControl.getValue("age"); 67 | }, 68 | }; 69 | }; 70 | 71 | const [Context] = createSubscriberContext({ initialState, createActions }); 72 | const { result } = renderHook(() => useContext(Context)); 73 | 74 | expect(result.current.getState()).toMatchObject(initialState); 75 | expect(result.current.getValue("age")).toBe(initialState.age); 76 | 77 | result.current.setValue("age", initialState.age + 10); 78 | expect(result.current.getValue("age")).toBe(initialState.age); 79 | 80 | result.current.setState({ age: initialState.age + 10 }); 81 | expect(result.current.getState()).toMatchObject(initialState); 82 | expect(console.error).toBeCalledTimes(6); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/examples/SubscriberDemo/SubscriberList.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptionsContext } from "components/PerformanceOptions/PerformanceOptionsContext"; 2 | import { Style } from "definitions/common-types"; 3 | import { memo, ReactElement, useContext, useEffect } from "react"; 4 | import styled from "styled-components"; 5 | import { getIncrementedCharValue } from "utils/getIncrementedCharValue"; 6 | import { getIncrementedNumValue } from "utils/getIncrementedNumValue"; 7 | import { EmailItem } from "./EmailItem"; 8 | import { SubscribedItem } from "./SubscribedItem"; 9 | import { 10 | NUM_SUBSCRIBED_ITEMS, 11 | SubscriberContext, 12 | SubscriberKey, 13 | SubscriberState, 14 | } from "./SubscriberContext"; 15 | 16 | const style: Style = { 17 | display: "block", 18 | maxWidth: "100%", 19 | }; 20 | 21 | const StyledButtonContainer = styled.div` 22 | padding: 16px; 23 | 24 | button { 25 | padding: 12px; 26 | margin: 0px 6px; 27 | cursor: pointer; 28 | } 29 | `; 30 | 31 | const MemoizedSubscribedItem = memo(SubscribedItem); 32 | 33 | export const SubscriberList = (): ReactElement => { 34 | const { getValue, setState } = useContext(SubscriberContext.Context); 35 | const { 36 | state: { numElements, shouldUseMemo }, 37 | } = useContext(PerformanceOptionsContext); 38 | const keys: SubscriberKey[] = []; 39 | 40 | for (let i = 0; i < numElements; i++) { 41 | keys.push(i % 2 === 0 ? `prop-num-${i}` : `prop-str-${i}`); 42 | } 43 | 44 | const handleClickUpdateStrings = () => { 45 | const nextState: Partial = {}; 46 | 47 | for (let i = 1; i < numElements; i += 2) { 48 | nextState[`prop-str-${i}`] = getIncrementedCharValue(getValue(`prop-str-${i}`)); 49 | } 50 | setState(nextState); 51 | }; 52 | 53 | const handleClickUpdateNumbers = () => { 54 | const nextState: Partial = {}; 55 | 56 | for (let i = 0; i < numElements; i += 2) { 57 | nextState[`prop-num-${i}`] = getIncrementedNumValue(getValue(`prop-num-${i}`)); 58 | } 59 | setState(nextState); 60 | }; 61 | 62 | useEffect(() => { 63 | const nextState: SubscriberState = {}; 64 | let num = 0; 65 | let char = "A"; 66 | 67 | if (NUM_SUBSCRIBED_ITEMS % 2 === 0) { 68 | num = getValue(`prop-num-${NUM_SUBSCRIBED_ITEMS - 2}`); 69 | char = getValue(`prop-str-${NUM_SUBSCRIBED_ITEMS - 1}`); 70 | } else { 71 | num = getValue(`prop-num-${NUM_SUBSCRIBED_ITEMS - 1}`); 72 | char = getValue(`prop-str-${NUM_SUBSCRIBED_ITEMS - 2}`); 73 | } 74 | 75 | for (let i = NUM_SUBSCRIBED_ITEMS; i < numElements; i++) { 76 | if (i % 2 === 0) { 77 | num = getIncrementedNumValue(num); 78 | nextState[`prop-num-${i}`] = num; 79 | } else { 80 | char = getIncrementedCharValue(char); 81 | nextState[`prop-str-${i}`] = char; 82 | } 83 | } 84 | 85 | setState(nextState); 86 | }, [numElements, setState, getValue]); 87 | 88 | return ( 89 |
90 | 91 | 92 | 93 | 94 | 95 |
96 | {keys.map((key) => 97 | shouldUseMemo ? ( 98 | 99 | ) : ( 100 | 101 | ) 102 | )} 103 |
104 |
105 | ); 106 | }; 107 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/MessagingDemo.tsx: -------------------------------------------------------------------------------- 1 | import { Conversations as MessageConversations } from "examples/MessagingDemo/Conversations"; 2 | import { VanillaConversations } from "examples/MessagingDemo/Conversations/VanillaConversations"; 3 | import { MessageHeader } from "examples/MessagingDemo/MessageHeader"; 4 | import { MessageHistory } from "examples/MessagingDemo/MessageHistory"; 5 | import { VanillaMessageHistory } from "examples/MessagingDemo/MessageHistory/VanillaMessageHistory"; 6 | import { MessageInput } from "examples/MessagingDemo/MessageInput"; 7 | import { MessagingSubscriberProvider } from "examples/MessagingDemo/MessagingSubscriberContext"; 8 | import { PopularModeButton } from "examples/MessagingDemo/PopularModeButton"; 9 | import { VanillaMessageHeader } from "examples/MessagingDemo/VanillaMessageHeader"; 10 | import { VanillaMessageInput } from "examples/MessagingDemo/VanillaMessageInput"; 11 | import { VanillaMessagingProvider } from "examples/MessagingDemo/VanillaMessagingContext"; 12 | import { ReactElement, useState } from "react"; 13 | import styled from "styled-components"; 14 | 15 | export const MessagingDemo = (): ReactElement => { 16 | const [isVanilla, setIsVanilla] = useState(false); 17 | let Provider = MessagingSubscriberProvider; 18 | let Header = MessageHeader; 19 | let History = MessageHistory; 20 | let Input = MessageInput; 21 | let Conversations = MessageConversations; 22 | 23 | if (isVanilla) { 24 | Provider = VanillaMessagingProvider; 25 | Header = VanillaMessageHeader; 26 | History = VanillaMessageHistory; 27 | Input = VanillaMessageInput; 28 | Conversations = VanillaConversations; 29 | } 30 | 31 | return ( 32 | 33 | 34 | { 36 | setIsVanilla((val) => !val); 37 | }} 38 | > 39 | Vanilla Mode ({isVanilla ? "On" : "Off"}) 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Designed inspired by{" "} 57 | 62 | Emy Lascan 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | const StyledButton = styled.button` 70 | padding: 12px; 71 | `; 72 | 73 | const StyledFooter = styled.div` 74 | color: whitesmoke; 75 | padding: 12px; 76 | 77 | a { 78 | color: whitesmoke; 79 | 80 | &:visited { 81 | color: whitesmoke; 82 | } 83 | } 84 | `; 85 | 86 | const StyledAppContainer = styled.div` 87 | height: 450px; 88 | max-width: 720px; 89 | margin: auto; 90 | `; 91 | 92 | const StyledContainer = styled.div` 93 | display: flex; 94 | gap: 24px; 95 | height: 100%; 96 | padding: 32px; 97 | background: white; 98 | border-radius: 12px; 99 | `; 100 | 101 | const StyledMessengerBody = styled.div` 102 | flex: 2; 103 | height: 100%; 104 | display: flex; 105 | flex-direction: column; 106 | `; 107 | 108 | const StyledMessengerWindow = styled.div` 109 | background-color: #f3f6fb; 110 | padding: 24px; 111 | border-radius: 12px; 112 | flex: 1; 113 | display: flex; 114 | flex-direction: column; 115 | overflow: auto; 116 | `; 117 | 118 | const StyledConversationsSection = styled.div` 119 | width: 33%; 120 | max-width: 280px; 121 | min-width: 200px; 122 | `; 123 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { PerformanceOptionsProvider } from "components/PerformanceOptions/PerformanceOptionsProvider"; 2 | import { AdvancedDemo } from "examples/AdvancedDemo/AdvancedDemo"; 3 | import { ADVANCED_COLOR } from "examples/AdvancedDemo/colors"; 4 | import { BasicDemo } from "examples/BasicDemo/BasicDemo"; 5 | import { BASIC_COLOR } from "examples/BasicDemo/colors"; 6 | import { DeepSubscriberDemo } from "examples/DeepSubscriberDemo/DeepSubscriberDemo"; 7 | import { MessagingDemo } from "examples/MessagingDemo/MessagingDemo"; 8 | import { ReactTrackDemo } from "examples/ReactTrackDemo/ReactTrackDemo"; 9 | import { SpiderManDemo } from "examples/SpiderManDemo"; 10 | import { SUBSCRIBER_COLOR } from "examples/SubscriberDemo/colors"; 11 | import { SubscriberDemo } from "examples/SubscriberDemo/SubscriberDemo"; 12 | import React, { useState } from "react"; 13 | import styled from "styled-components"; 14 | import "./App.css"; 15 | import { Style } from "./types/common-types"; 16 | 17 | const buttonContainerStyle: Style = { 18 | marginTop: 12, 19 | }; 20 | 21 | const buttonStyle: Style = { 22 | padding: 12, 23 | margin: 4, 24 | }; 25 | 26 | const StyledButton = styled("button")` 27 | cursor: pointer; 28 | 29 | &.active { 30 | font-weight: bold; 31 | 32 | &.Basic_Context { 33 | color: ${BASIC_COLOR}; 34 | } 35 | 36 | &.Advanced_Context { 37 | color: ${ADVANCED_COLOR}; 38 | } 39 | 40 | &.Subscriber_Context { 41 | color: ${SUBSCRIBER_COLOR}; 42 | } 43 | } 44 | `; 45 | 46 | function App() { 47 | const apps = [ 48 | "Basic Context", 49 | // "Memo Demo", 50 | "Use Case Demo", 51 | "Advanced Context", 52 | "Subscriber Context", 53 | "Deep Subscriber Demo", 54 | "Tracked Demo", 55 | "Messenger Demo", 56 | ] as const; 57 | const [selectedAppName, setApp] = useState("Messenger Demo"); 58 | 59 | let app; 60 | 61 | switch (selectedAppName) { 62 | case "Basic Context": 63 | app = ; 64 | break; 65 | // case "Memo Demo": 66 | // app = ; 67 | // break; 68 | case "Use Case Demo": 69 | app = ; 70 | break; 71 | case "Messenger Demo": 72 | app = ; 73 | break; 74 | case "Subscriber Context": 75 | app = ; 76 | break; 77 | case "Advanced Context": 78 | app = ; 79 | break; 80 | case "Tracked Demo": 81 | app = ; 82 | break; 83 | case "Deep Subscriber Demo": 84 | app = ; 85 | break; 86 | default: 87 | app = ; 88 | } 89 | 90 | return ( 91 |
92 |
93 |
94 | {apps.map((appName) => ( 95 | setApp(appName)} 99 | className={`${appName.replaceAll(" ", "_")} ${ 100 | appName === selectedAppName ? "active" : "" 101 | }`} 102 | > 103 | {appName} 104 | 105 | ))} 106 |
107 |

{selectedAppName}

108 | 109 | 110 | {app} 111 | 112 | 113 |
114 |
115 | ); 116 | } 117 | 118 | const AppContainer = styled.div<{ appName?: string }>` 119 | padding: 32px; 120 | border: 1px solid white; 121 | 122 | &.Messenger_Demo { 123 | border: none; 124 | padding: 0; 125 | } 126 | `; 127 | 128 | export default App; 129 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/Conversations/useVanillaConversations.ts: -------------------------------------------------------------------------------- 1 | import { Conversation, FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 2 | import { MessageInfo } from "examples/MessagingDemo/types"; 3 | import { useSubscribeMessageSocket } from "examples/MessagingDemo/useSubscribeMessageSocket"; 4 | import { VanillaMessagingContext } from "examples/MessagingDemo/VanillaMessagingContext"; 5 | import { ChangeEvent, useCallback, useContext, useEffect, useState } from "react"; 6 | 7 | export const useVanillaConversations = () => { 8 | const [search, setSearch] = useState(""); 9 | const { conversations, currentUser, selectedReceiverName, setState } = 10 | useContext(VanillaMessagingContext); 11 | const lowercasedSearch = search.toLowerCase(); 12 | const filteredConversations = search 13 | ? conversations.filter((conversation) => 14 | conversation.name.toLowerCase().includes(lowercasedSearch) 15 | ) 16 | : conversations; 17 | 18 | const handleIncomingMessage = useCallback( 19 | (messageInfo: MessageInfo) => { 20 | const prevConversations = conversations.slice(); 21 | const recentConversationIndex = prevConversations.findIndex( 22 | (c) => c.name === messageInfo.senderName 23 | ); 24 | 25 | if (recentConversationIndex >= 0) { 26 | const isTalkingToSender = selectedReceiverName === messageInfo.senderName; 27 | const [recentConversation] = prevConversations.splice(recentConversationIndex, 1); 28 | const nextConversations: Conversation[] = [ 29 | { 30 | ...recentConversation, 31 | recentMessage: messageInfo, 32 | numUnreadMessages: 33 | recentConversation.numUnreadMessages + (isTalkingToSender ? 0 : 1), 34 | }, 35 | ...prevConversations, 36 | ]; 37 | 38 | setState({ conversations: nextConversations }); 39 | } 40 | }, 41 | [setState, conversations, selectedReceiverName] 42 | ); 43 | 44 | const handleOutgoingMessage = useCallback( 45 | (messageInfo: MessageInfo) => { 46 | const prevConversations = conversations.slice(); 47 | const recentConversationIndex = prevConversations.findIndex( 48 | (c) => c.name === messageInfo.receiverName 49 | ); 50 | 51 | if (recentConversationIndex >= 0) { 52 | const [recentConversation] = prevConversations.splice(recentConversationIndex, 1); 53 | const nextConversations: Conversation[] = [ 54 | { ...recentConversation, recentMessage: messageInfo }, 55 | ...prevConversations, 56 | ]; 57 | 58 | setState({ conversations: nextConversations }); 59 | } 60 | }, 61 | [conversations, setState] 62 | ); 63 | 64 | const handleMessageRead = useCallback((messageRead: MessageInfo) => { 65 | // messageRead.receiverName 66 | }, []); 67 | 68 | useSubscribeMessageSocket("message-from-friend", handleIncomingMessage); 69 | useSubscribeMessageSocket("message-to-friend", handleOutgoingMessage); 70 | useSubscribeMessageSocket("message-read-by-friend", handleMessageRead); 71 | 72 | useEffect(() => { 73 | const fetchConversations = async () => { 74 | const data = await FakeMessenger.getConversations(currentUser.name); 75 | 76 | setState({ conversations: data, selectedReceiverName: data[0].name }); 77 | }; 78 | 79 | fetchConversations(); 80 | }, [currentUser, setState]); 81 | 82 | const handleClickConversation: React.MouseEventHandler = useCallback( 83 | (e) => { 84 | const target = e.currentTarget as HTMLDivElement; 85 | const contactName = target.dataset["contactname"]; 86 | 87 | if (!contactName) { 88 | throw new Error("Somehow conversation data-contactname doesn't exist on list item"); 89 | } 90 | 91 | (function readCurrentMessages() { 92 | setState(({ conversations: prevConversations, ...others }) => ({ 93 | ...others, 94 | selectedReceiverName: contactName, 95 | conversations: prevConversations.map((conversation) => { 96 | if (conversation.name === contactName) { 97 | return { ...conversation, numUnreadMessages: 0 }; 98 | } 99 | return conversation; 100 | }), 101 | })); 102 | FakeMessenger.userReadMessages(contactName); 103 | })(); 104 | }, 105 | [setState] 106 | ); 107 | 108 | const handleChangeSearch = (e: ChangeEvent) => { 109 | setSearch(e.target.value); 110 | }; 111 | 112 | return { 113 | conversations: filteredConversations, 114 | onChangeSearch: handleChangeSearch, 115 | onClickConversation: handleClickConversation, 116 | search, 117 | selectedReceiverName, 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/Conversations/useConversations.ts: -------------------------------------------------------------------------------- 1 | import { Conversation, FakeMessenger } from "examples/MessagingDemo/FakeMessenger"; 2 | import { MessagingSubscriberContext } from "examples/MessagingDemo/MessagingSubscriberContext"; 3 | import { MessageInfo } from "examples/MessagingDemo/types"; 4 | import { useSubscribeMessageSocket } from "examples/MessagingDemo/useSubscribeMessageSocket"; 5 | import { ChangeEvent, useCallback, useContext, useEffect, useState } from "react"; 6 | import { useSubscribe } from "react-subscribe-context/useSubscribe"; 7 | 8 | export const useConversations = () => { 9 | const [search, setSearch] = useState(""); 10 | const [selectedReceiverName] = useSubscribe(MessagingSubscriberContext, "selectedReceiverName"); 11 | const [conversations, setConversations] = useSubscribe( 12 | MessagingSubscriberContext, 13 | "conversations" 14 | ); 15 | const { getValue, setState } = useContext(MessagingSubscriberContext); 16 | const lowercasedSearch = search.toLowerCase(); 17 | const filteredConversations = search 18 | ? conversations.filter((conversation) => 19 | conversation.name.toLowerCase().includes(lowercasedSearch) 20 | ) 21 | : conversations; 22 | 23 | const handleIncomingMessage = useCallback( 24 | (messageInfo: MessageInfo) => { 25 | const prevConversations = getValue("conversations").slice(); 26 | const recentConversationIndex = prevConversations.findIndex( 27 | (c) => c.name === messageInfo.senderName 28 | ); 29 | 30 | if (recentConversationIndex >= 0) { 31 | const isTalkingToSender = 32 | getValue("selectedReceiverName") === messageInfo.senderName; 33 | const [recentConversation] = prevConversations.splice(recentConversationIndex, 1); 34 | const nextConversations: Conversation[] = [ 35 | { 36 | ...recentConversation, 37 | recentMessage: messageInfo, 38 | numUnreadMessages: 39 | recentConversation.numUnreadMessages + (isTalkingToSender ? 0 : 1), 40 | }, 41 | ...prevConversations, 42 | ]; 43 | 44 | setConversations(nextConversations); 45 | } 46 | }, 47 | [getValue, setConversations] 48 | ); 49 | 50 | const handleOutgoingMessage = useCallback( 51 | (messageInfo: MessageInfo) => { 52 | const prevConversations = getValue("conversations").slice(); 53 | const recentConversationIndex = prevConversations.findIndex( 54 | (c) => c.name === messageInfo.receiverName 55 | ); 56 | 57 | if (recentConversationIndex >= 0) { 58 | const [recentConversation] = prevConversations.splice(recentConversationIndex, 1); 59 | const nextConversations: Conversation[] = [ 60 | { ...recentConversation, recentMessage: messageInfo }, 61 | ...prevConversations, 62 | ]; 63 | 64 | setConversations(nextConversations); 65 | } 66 | }, 67 | [getValue, setConversations] 68 | ); 69 | 70 | const handleMessageRead = useCallback((messageRead: MessageInfo) => { 71 | // messageRead.receiverName 72 | }, []); 73 | 74 | useSubscribeMessageSocket("message-from-friend", handleIncomingMessage); 75 | useSubscribeMessageSocket("message-to-friend", handleOutgoingMessage); 76 | useSubscribeMessageSocket("message-read-by-friend", handleMessageRead); 77 | 78 | useEffect(() => { 79 | const fetchConversations = async () => { 80 | const data = await FakeMessenger.getConversations(getValue("currentUser").name); 81 | 82 | setState({ selectedReceiverName: data[0].name, conversations: data }); 83 | }; 84 | 85 | fetchConversations(); 86 | }, [setState, getValue]); 87 | 88 | const handleClickConversation: React.MouseEventHandler = useCallback( 89 | (e) => { 90 | const target = e.currentTarget as HTMLDivElement; 91 | const contactName = target.dataset["contactname"]; 92 | 93 | if (!contactName) { 94 | throw new Error("Somehow conversation data-contactname doesn't exist on list item"); 95 | } 96 | 97 | setState({ selectedReceiverName: contactName }); 98 | 99 | (function readCurrentMessages() { 100 | setConversations((prevConversations) => 101 | prevConversations.map((conversation) => { 102 | if (conversation.name === contactName) { 103 | return { ...conversation, numUnreadMessages: 0 }; 104 | } 105 | return conversation; 106 | }) 107 | ); 108 | FakeMessenger.userReadMessages(contactName); 109 | })(); 110 | }, 111 | [setState, setConversations] 112 | ); 113 | 114 | const handleChangeSearch = (e: ChangeEvent) => { 115 | setSearch(e.target.value); 116 | }; 117 | 118 | return { 119 | conversations: filteredConversations, 120 | onChangeSearch: handleChangeSearch, 121 | onClickConversation: handleClickConversation, 122 | search, 123 | selectedReceiverName, 124 | }; 125 | }; 126 | -------------------------------------------------------------------------------- /src/react-subscribe-context/useSubscribe.ts: -------------------------------------------------------------------------------- 1 | import deepProxy from 'deep-proxy-polyfill'; 2 | import { Context, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; 3 | import { ContextControl, EventKey } from './context-control-types'; 4 | import { createProxyHandler, SubscribedCache } from './createProxyHandler'; 5 | import { getUpdateEventName } from './getUpdateEventName'; 6 | 7 | const getSubscribedEvents = (subscribedCache: SubscribedCache) => { 8 | return (Object.keys(subscribedCache) as EventKey[]).filter((path) => subscribedCache[path]); 9 | }; 10 | 11 | /** 12 | * Function that returns the new value of the field. 13 | * @param value Current value of the field. 14 | * @param state Current state 15 | * @returns Next value 16 | */ 17 | type GetNextValue = ( 18 | value: TState[TKey], 19 | state: TState 20 | ) => TState[TKey]; 21 | 22 | interface UpdateValue { 23 | /** 24 | * The new value of the field. 25 | */ 26 | (nextValue: TState[TKey]): void; 27 | 28 | /** 29 | * Function that returns the new value of the field. 30 | */ 31 | (getNextValue: GetNextValue): void; 32 | } 33 | 34 | /** 35 | * An array of the value, value setter and context control 36 | */ 37 | type UseSubscribeValueArrayReturn< 38 | TState, 39 | TKey extends keyof TState & string, 40 | TActions extends object 41 | > = [TState[TKey], UpdateValue, TActions, ContextControl]; 42 | 43 | /** 44 | * An object holding the value, value setter and context control 45 | */ 46 | type UseSubscribeValueObjectReturn< 47 | TState, 48 | TKey extends keyof TState & string, 49 | TActions extends object 50 | > = { 51 | value: TState[TKey]; 52 | setValue: UpdateValue; 53 | actions: TActions; 54 | contextControl: ContextControl; 55 | }; 56 | 57 | export type UseSubscribeValueReturn< 58 | TState, 59 | TKey extends keyof TState & string, 60 | TActions extends object 61 | > = UseSubscribeValueArrayReturn & 62 | UseSubscribeValueObjectReturn; 63 | 64 | /** 65 | * An array of the state, state setter and context control 66 | */ 67 | type UseSubscribeStateArrayReturn = [ 68 | TState, 69 | ContextControl['setState'], 70 | TActions, 71 | ContextControl 72 | ]; 73 | 74 | /** 75 | * An object holding the state, state setter and context control 76 | */ 77 | type UseSubscribeStateObjectReturn = { 78 | state: TState; 79 | setState: ContextControl['setState']; 80 | actions: TActions; 81 | contextControl: ContextControl; 82 | }; 83 | 84 | export type UseSubscribeStateReturn = UseSubscribeStateArrayReturn< 85 | TState, 86 | TActions 87 | > & 88 | UseSubscribeStateObjectReturn; 89 | 90 | /** 91 | * Accesses the control context and subscribes to specified value. 92 | * @param Context Control context that will be referenced. 93 | * @param key Field to access and subscribe to. 94 | */ 95 | export function useSubscribe( 96 | Context: Context>, 97 | key: TKey 98 | ): UseSubscribeValueReturn; 99 | 100 | /** 101 | * Accesses to the control context and subscribes to any value accessed from the returned state. 102 | * @param Context Control context that will be referenced. 103 | * @param key Undefined field key will return a state. 104 | */ 105 | export function useSubscribe( 106 | Context: Context>, 107 | key?: undefined | null 108 | ): UseSubscribeStateReturn; 109 | 110 | export function useSubscribe< 111 | TState extends object, 112 | TKey extends keyof TState & string, 113 | TActions extends object 114 | >( 115 | Context: Context>, 116 | key: TKey | undefined | null 117 | ): UseSubscribeValueReturn | UseSubscribeStateReturn { 118 | const contextControl = useContext(Context); 119 | const { emitter, getState, getValue, setValue, setState } = contextControl; 120 | const [, setFakeValue] = useState({}); 121 | const rerender = useCallback(() => setFakeValue({}), []); 122 | const subscribedCacheRef = useRef({}); 123 | const numEvents = Object.keys(subscribedCacheRef.current).length; 124 | 125 | const stateProxyHandler = useMemo( 126 | () => createProxyHandler(subscribedCacheRef, rerender), 127 | [rerender] 128 | ); 129 | 130 | const valueProxyHandler = useMemo( 131 | () => createProxyHandler(subscribedCacheRef, rerender, key as string), 132 | [rerender, key] 133 | ); 134 | 135 | useEffect(() => { 136 | if (key) { 137 | const value = getValue(key); 138 | 139 | if (typeof value !== 'object' || Array.isArray(value)) { 140 | subscribedCacheRef.current[getUpdateEventName(key)] = true; 141 | } 142 | } 143 | 144 | const events = getSubscribedEvents(subscribedCacheRef.current); 145 | 146 | events.forEach((event) => { 147 | emitter.on(event, rerender); 148 | }); 149 | 150 | return () => { 151 | events.forEach((event) => { 152 | emitter.off(event, rerender); 153 | }); 154 | }; 155 | }, [emitter, rerender, key, getValue, numEvents]); 156 | 157 | if (key) { 158 | const value = getValue(key); 159 | let result: any[] & any = [getValue(key)]; 160 | 161 | if (typeof value === 'object' && !Array.isArray(value)) { 162 | result = [deepProxy(value, valueProxyHandler)]; 163 | } 164 | 165 | const updateValue: UpdateValue = (value) => { 166 | if (value instanceof Function) { 167 | setValue(key, value); 168 | } else { 169 | setValue(key, value); 170 | } 171 | }; 172 | 173 | result.push(updateValue, contextControl.actions, contextControl); 174 | 175 | result.value = result[0]; 176 | result.setValue = updateValue; 177 | result.contextControl = contextControl; 178 | result.actions = contextControl.actions; 179 | 180 | return result as UseSubscribeValueReturn; 181 | } 182 | 183 | const result: any = [ 184 | deepProxy(getState(), stateProxyHandler), 185 | setState, 186 | contextControl.actions, 187 | contextControl, 188 | ]; 189 | 190 | result.state = result[0]; 191 | result.setState = setState; 192 | result.contextControl = contextControl; 193 | result.actions = contextControl.actions; 194 | 195 | return result as UseSubscribeStateReturn; 196 | } 197 | -------------------------------------------------------------------------------- /src/react-subscribe-context/useSubscribe.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | act, 3 | Renderer, 4 | renderHook, 5 | RenderHookOptions, 6 | RenderHookResult, 7 | } from '@testing-library/react-hooks'; 8 | import { createSubscriberContext } from '.'; 9 | import { useSubscribe, UseSubscribeStateReturn, UseSubscribeValueReturn } from './useSubscribe'; 10 | 11 | describe('useSubscribeProvider', () => { 12 | const initialState = { 13 | user: { 14 | name: { 15 | first: 'Peter', 16 | last: 'Parker', 17 | }, 18 | }, 19 | age: 24, 20 | items: ['web', 'mask', 'gloves'], 21 | }; 22 | 23 | type State = typeof initialState; 24 | 25 | const [Context, Provider] = createSubscriberContext({ initialState }); 26 | 27 | const renderHookOptions: RenderHookOptions<{}> = { 28 | wrapper: ({ children }) => {children}, 29 | }; 30 | 31 | const renderUseSubscribeValue = ( 32 | field: TKey 33 | ): RenderHookResult<{}, UseSubscribeValueReturn, Renderer<{}>> => { 34 | return renderHook(() => useSubscribe(Context, field), renderHookOptions); 35 | }; 36 | 37 | const renderUseSubscribeState = (): RenderHookResult< 38 | {}, 39 | UseSubscribeStateReturn, 40 | Renderer<{}> 41 | > => { 42 | return renderHook(() => useSubscribe(Context), renderHookOptions); 43 | }; 44 | 45 | describe('by value', () => { 46 | describe('object destructuring', () => { 47 | it('should return correct value', () => { 48 | const { result } = renderUseSubscribeValue('age'); 49 | const { value } = result.current; 50 | 51 | expect(value).toBe(initialState.age); 52 | }); 53 | it('should return proxy value if field is an object', () => { 54 | const { result } = renderUseSubscribeValue('user'); 55 | const { value, contextControl } = result.current; 56 | 57 | expect(value).not.toBe(contextControl.getState().user); 58 | 59 | act(() => { 60 | expect(value).toMatchObject(contextControl.getState().user); 61 | }); 62 | }); 63 | it('should return regular value if field is an array', () => { 64 | const { result } = renderUseSubscribeValue('items'); 65 | const { value, contextControl } = result.current; 66 | 67 | expect(value).toBe(contextControl.getValue('items')); 68 | }); 69 | it('should set values properly with setValue', async () => { 70 | const { result } = renderUseSubscribeValue('age'); 71 | const { setValue } = result.current; 72 | 73 | act(() => { 74 | setValue(initialState.age + 10); 75 | }); 76 | expect(result.current.value).toBe(initialState.age + 10); 77 | 78 | let currAge = result.current.value; 79 | 80 | act(() => { 81 | setValue((age) => age + 20); 82 | }); 83 | expect(result.current.value).toBe(currAge + 20); 84 | 85 | currAge = result.current.value; 86 | 87 | act(() => { 88 | setValue((_, state) => state.age + 20); 89 | }); 90 | expect(result.current.value).toBe(currAge + 20); 91 | }); 92 | }); 93 | describe('array destructuring', () => { 94 | it('should return correct values as object destructuring', () => { 95 | const { result } = renderUseSubscribeValue('user'); 96 | 97 | const { actions, contextControl, value, setValue } = result.current; 98 | const [aValue, aSetValue, aActions, aContextControl] = result.current; 99 | 100 | expect(aActions).toBe(actions); 101 | expect(aContextControl).toBe(contextControl); 102 | expect(aValue).toBe(value); 103 | expect(aSetValue).toBe(setValue); 104 | }); 105 | }); 106 | }); 107 | 108 | describe('by state', () => { 109 | describe('object destructuring', () => { 110 | it('should return correct value', () => { 111 | const { result } = renderUseSubscribeState(); 112 | 113 | act(() => { 114 | const { state } = result.current; 115 | 116 | expect(state).toMatchObject(initialState); 117 | }); 118 | }); 119 | it('should set state properly with setState', async () => { 120 | const { result } = renderUseSubscribeState(); 121 | const { setState } = result.current; 122 | 123 | // subscribe to age 124 | await act(async () => { 125 | (() => result.current.state.age)(); 126 | }); 127 | 128 | let currProxyState = result.current.state; 129 | 130 | act(() => { 131 | setState({ age: currProxyState.age + 10 }); 132 | }); 133 | 134 | expect(result.current.state.age).toBe(currProxyState.age + 10); 135 | currProxyState = result.current.state; 136 | 137 | act(() => { 138 | setState((state) => ({ age: state.age + 20 })); 139 | }); 140 | 141 | expect(result.current.state.age).toBe(currProxyState.age + 20); 142 | expect(result.current.state).not.toBe(currProxyState); 143 | }); 144 | it('should render only when subscribed value changes', async () => { 145 | const { result } = renderUseSubscribeState(); 146 | const { setState } = result.current; 147 | 148 | // subscribe to age 149 | await act(async () => { 150 | (() => result.current.state.age)(); 151 | }); 152 | 153 | let currProxyState = result.current.state; 154 | 155 | act(() => { 156 | setState({ age: currProxyState.age + 10 }); 157 | }); 158 | 159 | expect(result.current.state.age).toBe(currProxyState.age + 10); 160 | currProxyState = result.current.state; 161 | 162 | act(() => { 163 | setState((state) => ({ 164 | user: { name: { ...state.user.name, first: 'Miles' } }, 165 | })); 166 | }); 167 | 168 | act(() => { 169 | const user = result.current.contextControl.getValue('user'); 170 | 171 | expect(user.name.first).toBe('Miles'); 172 | expect(result.current.state.user.name.first).not.toBe('Miles'); 173 | expect(result.current.state).toMatchObject(currProxyState); 174 | }); 175 | }); 176 | it('should render only for subscribed nested value changes', async () => { 177 | const { result } = renderUseSubscribeState(); 178 | const { setState } = result.current; 179 | 180 | // subscribe to user.name.first 181 | await act(async () => { 182 | (() => result.current.state.user.name.first)(); 183 | }); 184 | 185 | act(() => { 186 | setState(({ user }) => ({ 187 | user: { name: { first: 'Miles', last: user.name.last } }, 188 | })); 189 | }); 190 | 191 | expect(result.current.state.user.name.first).toBe('Miles'); 192 | 193 | act(() => { 194 | setState(({ user }) => ({ 195 | user: { name: { first: user.name.first, last: 'Porker' } }, 196 | })); 197 | }); 198 | 199 | expect(result.current.contextControl.getValue('user').name.last).toBe('Porker'); 200 | 201 | act(() => { 202 | expect(result.current.state.user.name.last).toBe('Parker'); 203 | }); 204 | }); 205 | }); 206 | describe('array destructuring', () => { 207 | it('should return correct values as object destructuring', () => { 208 | const { result } = renderUseSubscribeState(); 209 | 210 | const { actions, contextControl, state, setState } = result.current; 211 | const [aState, aSetState, aActions, aContextControl] = result.current; 212 | 213 | expect(aActions).toBe(actions); 214 | expect(aContextControl).toBe(contextControl); 215 | expect(aState).toBe(state); 216 | expect(aSetState).toBe(setState); 217 | }); 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /src/react-subscribe-context/useSubscribeProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react-hooks"; 2 | import { EventEmitter } from "events"; 3 | import { ActionsCreator, BaseContextControl, ContextControl } from "./context-control-types"; 4 | import { useSubscribeProvider } from "./useSubscribeProvider"; 5 | 6 | describe("useSubscribeProvider", () => { 7 | const initialState = { 8 | user: { 9 | name: { 10 | first: "Peter", 11 | last: "Parker", 12 | }, 13 | }, 14 | age: 24, 15 | }; 16 | 17 | type State = typeof initialState; 18 | 19 | const baseControl: BaseContextControl = { 20 | emitter: new EventEmitter(), 21 | getState: jest.fn(() => initialState), 22 | getValue: jest.fn((fieldName) => initialState[fieldName]), 23 | setState: jest.fn(), 24 | setValue: jest.fn(), 25 | }; 26 | 27 | const initialControlWithoutActions: ContextControl = { 28 | ...baseControl, 29 | actions: {}, 30 | }; 31 | 32 | type Actions = { 33 | doSomething: () => BaseContextControl; 34 | }; 35 | 36 | const initialControlWithActions: ContextControl = { 37 | ...baseControl, 38 | actions: { 39 | doSomething: () => baseControl, 40 | }, 41 | }; 42 | 43 | const getUseSubscriberProviderResult = () => { 44 | const { result } = renderHook(() => 45 | useSubscribeProvider(initialControlWithoutActions, initialState) 46 | ); 47 | 48 | const mockEmit = jest.fn(); 49 | 50 | result.current.current.emitter.emit = mockEmit; 51 | 52 | return { controlContext: result.current.current, mockEmit }; 53 | }; 54 | 55 | const getUseSubscriberProviderWithActionsResult = () => { 56 | const createActions: ActionsCreator = (contextControl) => { 57 | return { 58 | doSomething: () => contextControl, 59 | }; 60 | }; 61 | const { result } = renderHook(() => 62 | useSubscribeProvider(initialControlWithActions, initialState, createActions) 63 | ); 64 | 65 | const mockEmit = jest.fn(); 66 | 67 | result.current.current.emitter.emit = mockEmit; 68 | 69 | return { controlContext: result.current.current, mockEmit }; 70 | }; 71 | 72 | describe("getValue", () => { 73 | const { controlContext } = getUseSubscriberProviderResult(); 74 | 75 | it("should return value given a field name", () => { 76 | expect(controlContext.getValue("age")).toBe(initialState.age); 77 | expect(controlContext.getValue("user")).toBe(initialState.user); 78 | }); 79 | }); 80 | 81 | describe("getState", () => { 82 | const { controlContext } = getUseSubscriberProviderResult(); 83 | 84 | it("should return the current state", () => { 85 | expect(controlContext.getState()).toMatchObject(initialState); 86 | }); 87 | }); 88 | 89 | describe("setValue", () => { 90 | it("should set value of specified field given a new value", () => { 91 | const { controlContext } = getUseSubscriberProviderResult(); 92 | const currVal = controlContext.getValue("age"); 93 | const nextVal = currVal + 10; 94 | 95 | controlContext.setValue("age", nextVal); 96 | 97 | expect(controlContext.getValue("age")).toBe(nextVal); 98 | }); 99 | it("should set value of specified field given a function and using previous value", () => { 100 | const { controlContext } = getUseSubscriberProviderResult(); 101 | const currVal = controlContext.getValue("age"); 102 | 103 | controlContext.setValue("age", (prevVal) => prevVal + 10); 104 | 105 | expect(controlContext.getValue("age")).toBe(currVal + 10); 106 | }); 107 | it("should set value of specified field given a function and using state", () => { 108 | const { controlContext } = getUseSubscriberProviderResult(); 109 | const currVal = controlContext.getValue("age"); 110 | 111 | controlContext.setValue("age", (_, state) => state.age + 10); 112 | 113 | expect(controlContext.getValue("age")).toBe(currVal + 10); 114 | }); 115 | it("should emit an update when a value has changed", () => { 116 | const { controlContext, mockEmit } = getUseSubscriberProviderResult(); 117 | const nextValue = controlContext.getValue("age") + 10; 118 | 119 | controlContext.setValue("age", nextValue); 120 | 121 | expect(mockEmit).toBeCalledTimes(1); 122 | expect(mockEmit.mock.calls).toEqual([["update-age", { age: nextValue }]]); 123 | }); 124 | it("should NOT emit an update when a value has NOT changed", () => { 125 | const { controlContext, mockEmit } = getUseSubscriberProviderResult(); 126 | 127 | controlContext.setValue("age", controlContext.getValue("age")); 128 | 129 | expect(mockEmit).toBeCalledTimes(0); 130 | }); 131 | it("should emit updates for all changed nested values", () => { 132 | const { controlContext, mockEmit } = getUseSubscriberProviderResult(); 133 | const user = controlContext.getValue("user"); 134 | const updatedUser = { ...user, name: { ...user.name, first: "Peni" } }; 135 | const partialUserUpdate = { user: updatedUser }; 136 | 137 | controlContext.setValue("user", updatedUser); 138 | 139 | expect(mockEmit).toBeCalledTimes(3); 140 | expect(mockEmit.mock.calls).toEqual([ 141 | ["update-user.name.first", partialUserUpdate], 142 | ["update-user.name", partialUserUpdate], 143 | ["update-user", partialUserUpdate], 144 | ]); 145 | }); 146 | }); 147 | 148 | describe("setState", () => { 149 | it("should set state given a whole new state", () => { 150 | const { controlContext } = getUseSubscriberProviderResult(); 151 | const currState = controlContext.getState(); 152 | const nextState = { ...currState, age: currState.age + 10 }; 153 | 154 | controlContext.setState(nextState); 155 | 156 | expect(controlContext.getState()).not.toBe(nextState); 157 | expect(controlContext.getState()).toMatchObject(nextState); 158 | }); 159 | it("should set state given a partial new state", () => { 160 | const { controlContext } = getUseSubscriberProviderResult(); 161 | const currState = controlContext.getState(); 162 | const partialNextState = { age: currState.age + 10 }; 163 | const nextState = { ...currState, age: currState.age + 10 }; 164 | 165 | controlContext.setState(partialNextState); 166 | 167 | expect(controlContext.getState()).toMatchObject(nextState); 168 | }); 169 | it("should set state given a function and using previous state", () => { 170 | const { controlContext } = getUseSubscriberProviderResult(); 171 | const currState = controlContext.getState(); 172 | 173 | controlContext.setState((prevState) => ({ 174 | ...prevState, 175 | age: prevState.age + 10, 176 | })); 177 | 178 | expect(controlContext.getState()).toMatchObject({ 179 | ...currState, 180 | age: currState.age + 10, 181 | }); 182 | }); 183 | it("should set state given a function and returning partial state", () => { 184 | const { controlContext } = getUseSubscriberProviderResult(); 185 | const currState = controlContext.getState(); 186 | 187 | controlContext.setState(() => ({ 188 | age: currState.age + 10, 189 | })); 190 | 191 | expect(controlContext.getState()).toMatchObject({ 192 | ...currState, 193 | age: currState.age + 10, 194 | }); 195 | }); 196 | it("should emit an update when a value has changed", () => { 197 | const { controlContext, mockEmit } = getUseSubscriberProviderResult(); 198 | const prevAge = controlContext.getValue("age"); 199 | 200 | controlContext.setState(({ age }) => ({ age: age + 10 })); 201 | 202 | expect(mockEmit).toBeCalledTimes(2); 203 | expect(mockEmit.mock.calls).toEqual([ 204 | ["update-state", { ...controlContext.getState(), age: prevAge + 10 }], 205 | ["update-age", { age: prevAge + 10 }], 206 | ]); 207 | }); 208 | it("should NOT emit an update when a value has NOT changed", () => { 209 | const { controlContext, mockEmit } = getUseSubscriberProviderResult(); 210 | 211 | controlContext.setState({ ...controlContext.getState() }); 212 | 213 | expect(mockEmit).toBeCalledTimes(0); 214 | }); 215 | it("should emit updates for all changed nested values", () => { 216 | const { controlContext, mockEmit } = getUseSubscriberProviderResult(); 217 | const user = controlContext.getValue("user"); 218 | const partialUserUpdate = { user: { ...user, name: { ...user.name, first: "Peni" } } }; 219 | 220 | controlContext.setState(partialUserUpdate); 221 | 222 | expect(mockEmit).toBeCalledTimes(4); 223 | expect(mockEmit.mock.calls).toEqual([ 224 | ["update-state", { ...controlContext.getState(), ...partialUserUpdate }], 225 | ["update-user.name.first", partialUserUpdate], 226 | ["update-user.name", partialUserUpdate], 227 | ["update-user", partialUserUpdate], 228 | ]); 229 | }); 230 | }); 231 | 232 | describe("actions", () => { 233 | it("should be empty when no createActions is passed in", () => { 234 | const { controlContext } = getUseSubscriberProviderResult(); 235 | 236 | expect(controlContext.actions).toMatchObject({}); 237 | }); 238 | it("should be created with controlContext", () => { 239 | const { controlContext } = getUseSubscriberProviderWithActionsResult(); 240 | 241 | expect(controlContext.actions.doSomething()).toBe(controlContext); 242 | }); 243 | }); 244 | }); 245 | -------------------------------------------------------------------------------- /src/examples/MessagingDemo/FakeMessenger.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { EVT_MESSAGE_FROM_FRIEND, EVT_MESSAGE_READ_BY_FRIEND } from "constants/event-names"; 3 | import { EventEmitter } from "events"; 4 | import { MessageInfo } from "examples/MessagingDemo/types"; 5 | 6 | interface SendMessageArgs { 7 | text: string; 8 | senderName: string; 9 | receiverName: string; 10 | } 11 | 12 | interface SendFakeMessageToUserArgs { 13 | authorName: string; 14 | text?: string; 15 | userName: string; 16 | messageToRead?: MessageInfo; 17 | } 18 | 19 | interface ConversationByReceiver { 20 | [receiverName: string]: MessageInfo[]; 21 | } 22 | 23 | export interface Quote { 24 | text: string; 25 | author: string; 26 | } 27 | 28 | export interface Conversation { 29 | name: string; 30 | numUnreadMessages: number; 31 | recentMessage?: MessageInfo; 32 | } 33 | 34 | let quotes: Quote[] = []; 35 | let quotesByName: { [name: string]: Quote[] } = {}; 36 | let isPopularModeOn = false; 37 | const conversationByReceiver: ConversationByReceiver = {}; 38 | const usedQuotesCache: { [key: string]: { [index: number]: true } } = {}; 39 | const emitter = new EventEmitter(); 40 | 41 | export class FakeMessenger { 42 | static async sendMessage({ 43 | receiverName, 44 | senderName, 45 | text, 46 | }: SendMessageArgs): Promise { 47 | const dateSent = new Date().toISOString(); 48 | const messageInfo: MessageInfo = { 49 | content: text, 50 | dateSent, 51 | id: `${senderName}${dateSent}`, 52 | senderName, 53 | receiverName, 54 | status: "sent", 55 | }; 56 | 57 | if (conversationByReceiver[receiverName]) { 58 | conversationByReceiver[receiverName].push(messageInfo); 59 | } else { 60 | conversationByReceiver[receiverName] = [messageInfo]; 61 | } 62 | 63 | FakeMessenger.sendFakeMessageToUser({ 64 | authorName: receiverName, 65 | userName: senderName, 66 | messageToRead: messageInfo, 67 | }); 68 | 69 | return messageInfo; 70 | } 71 | 72 | static async getMessages(contactName: string): Promise { 73 | return new Promise((resolve) => { 74 | setTimeout(() => { 75 | resolve( 76 | conversationByReceiver[contactName] 77 | ? conversationByReceiver[contactName].slice(0) 78 | : [] 79 | ); 80 | }, Math.floor(Math.random() * 1000)); 81 | }); 82 | } 83 | 84 | static async getConversations(senderName: string): Promise { 85 | return new Promise((resolve, reject) => { 86 | setTimeout(async () => { 87 | if (quotes.length === 0) { 88 | try { 89 | const response = await axios.get("https://type.fit/api/quotes"); 90 | 91 | quotes = response.data; 92 | 93 | quotes.forEach((quote) => { 94 | if (quotesByName[quote.author]) { 95 | quotesByName[quote.author].push(quote); 96 | } else { 97 | quotesByName[quote.author] = [quote]; 98 | } 99 | }); 100 | 101 | quotes.sort((a, b) => { 102 | const [aLength, bLength] = [ 103 | quotesByName[a.author] ? quotesByName[a.author].length : 0, 104 | quotesByName[b.author] ? quotesByName[b.author].length : 0, 105 | ]; 106 | 107 | if (aLength === bLength) { 108 | return 0; 109 | } 110 | 111 | return aLength > bLength ? -1 : 1; 112 | }); 113 | 114 | quotesByName = quotes.reduce((acc, quote) => { 115 | acc[quote.author] = quotesByName[quote.author]; 116 | 117 | return acc; 118 | }, {} as typeof quotesByName); 119 | 120 | delete quotesByName["null"]; 121 | } catch (err) { 122 | reject(err); 123 | } 124 | } 125 | 126 | const contactNames = Object.keys(quotesByName); 127 | const conversations = contactNames.map( 128 | (name): Conversation => ({ 129 | name, 130 | numUnreadMessages: conversationByReceiver[name] 131 | ? conversationByReceiver[name].reduce( 132 | (acc, c) => 133 | c.status !== "seen" && c.senderName !== senderName 134 | ? acc + 1 135 | : acc, 136 | 0 137 | ) 138 | : 0, 139 | recentMessage: 140 | conversationByReceiver[name] && 141 | conversationByReceiver[name][conversationByReceiver[name].length - 1] 142 | ? conversationByReceiver[name][ 143 | conversationByReceiver[name].length - 1 144 | ] 145 | : undefined, 146 | }) 147 | ); 148 | 149 | conversations.sort((a, b) => { 150 | if (a.recentMessage === b.recentMessage) { 151 | return 0; 152 | } 153 | 154 | if (a.recentMessage && b.recentMessage) { 155 | if (a.recentMessage?.dateSent > b.recentMessage?.dateSent) { 156 | return -1; 157 | } 158 | if (a.recentMessage?.dateSent < b.recentMessage?.dateSent) { 159 | return 1; 160 | } 161 | } 162 | 163 | return a.recentMessage ? -1 : 1; 164 | }); 165 | 166 | resolve(conversations); 167 | }, Math.floor(Math.random() * 1000)); 168 | }); 169 | } 170 | 171 | static getRandomQuote(authorName: string): string { 172 | let randomQuoteIndex = Math.floor(Math.random() * quotesByName[authorName].length); 173 | let randomQuote = quotesByName[authorName][randomQuoteIndex].text; 174 | const usedUpAllQuotes = 175 | usedQuotesCache[authorName] && 176 | Object.keys(usedQuotesCache[authorName]).length >= quotesByName[authorName].length; 177 | 178 | if (usedUpAllQuotes) { 179 | delete usedQuotesCache[authorName]; 180 | } 181 | 182 | while (usedQuotesCache[authorName] && usedQuotesCache[authorName][randomQuoteIndex]) { 183 | randomQuoteIndex = Math.floor(Math.random() * quotesByName[authorName].length); 184 | randomQuote = quotesByName[authorName][randomQuoteIndex].text; 185 | } 186 | 187 | usedQuotesCache[authorName] = { ...usedQuotesCache[authorName], [randomQuoteIndex]: true }; 188 | 189 | return randomQuote; 190 | } 191 | 192 | static async simulateReceiverReadMessage(messageToRead: MessageInfo): Promise { 193 | return new Promise((resolve) => { 194 | setTimeout(() => { 195 | const readMessage: MessageInfo = { ...messageToRead, status: "seen" }; 196 | 197 | const dbMessageInfo = conversationByReceiver[messageToRead.receiverName].find( 198 | (messageInfo) => { 199 | return messageInfo.id === messageToRead.id; 200 | } 201 | ); 202 | 203 | if (dbMessageInfo) { 204 | dbMessageInfo.status = "seen"; 205 | emitter.emit(EVT_MESSAGE_READ_BY_FRIEND, readMessage); 206 | } else { 207 | console.error("Couldn't read message", messageToRead); 208 | } 209 | resolve(); 210 | }, Math.floor(Math.random() * 500 + 500)); 211 | }); 212 | } 213 | 214 | static async userReadMessages(friendName: string) { 215 | if (conversationByReceiver[friendName]) { 216 | conversationByReceiver[friendName] = conversationByReceiver[friendName].map( 217 | (messageInfo) => { 218 | if (messageInfo.status !== "seen" && messageInfo.senderName === friendName) { 219 | return { ...messageInfo, status: "seen" }; 220 | } 221 | return messageInfo; 222 | } 223 | ); 224 | } 225 | } 226 | 227 | static async sendFakeMessageToUser({ 228 | authorName, 229 | text, 230 | userName, 231 | messageToRead, 232 | }: SendFakeMessageToUserArgs): Promise { 233 | if (messageToRead) { 234 | await FakeMessenger.simulateReceiverReadMessage(messageToRead); 235 | } 236 | 237 | return new Promise((resolve) => { 238 | setTimeout(() => { 239 | const randomQuote = !!text ? text : FakeMessenger.getRandomQuote(authorName); 240 | const dateSent = new Date().toISOString(); 241 | const messageInfo: MessageInfo = { 242 | content: randomQuote, 243 | dateSent, 244 | id: `${authorName}${dateSent}`, 245 | senderName: authorName, 246 | receiverName: userName, 247 | status: "sent", 248 | }; 249 | 250 | if (conversationByReceiver[authorName]) { 251 | conversationByReceiver[authorName].push(messageInfo); 252 | } else { 253 | conversationByReceiver[authorName] = [messageInfo]; 254 | } 255 | emitter.emit(EVT_MESSAGE_FROM_FRIEND, messageInfo); 256 | resolve(); 257 | }, Math.floor(1000 + Math.random() * 1000)); 258 | }); 259 | } 260 | 261 | static async simulatePopularMode(userName: string, numMessagesToSend = 25): Promise { 262 | if (!isPopularModeOn) { 263 | return new Promise((resolve) => { 264 | const authors = Object.keys(quotesByName); 265 | let counter = 0; 266 | 267 | let intervalId = setInterval(() => { 268 | const authorName = authors[Math.floor(Math.random() * authors.length)]; 269 | FakeMessenger.sendFakeMessageToUser({ authorName, userName }); 270 | 271 | if (counter++ === numMessagesToSend) { 272 | clearInterval(intervalId); 273 | isPopularModeOn = false; 274 | resolve(); 275 | } 276 | }, 500); 277 | 278 | isPopularModeOn = true; 279 | }); 280 | } 281 | } 282 | 283 | static createMessage() {} 284 | 285 | static getEmitter() { 286 | return emitter; 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Subscribe Context 2 | 3 | A consistent way of state management to avoid prop drilling, and is optimized for component rerenders. 4 | 5 | _And it's **IE11** compatible!_ 6 | 7 | ## Introduction 8 | 9 | React Context is an amazing tool to help avoid prop drilling, but it comes with a price. It's not a pleasant dev experience to create setters for every value you want in your context, and each time those values change, it'll rerender **every** child it holds, unless they're memoized, but who wants to put in that extra work to memoize everything? And even if they're memoized, if they're consumers, they'll **always** get rerendered. 10 | 11 | I was inspired by [react-hook-form](https://github.com/react-hook-form/react-hook-form) and their [useWatch](https://react-hook-form.com/api/usewatch/), which subscribed to only changes to a specific value of a form. I loved that feature and thought it'd be great if React Context could do that too. Then I learned about [react-tracked](https://github.com/dai-shi/react-tracked). 12 | It did exactly what I was looking for, except that it wasn't IE11 compatible, so I decided to create react-subscribe-context. 13 | 14 | Using Proxy and EventEmitter, I created a tool where you can subscribe to a value just by accessing it, and it works for nested objects as well. It's simple to set up and works similar to the React hook, `useState`! 15 | 16 | ## Rendering performance example 17 | 18 | With render highlighting on via React Developer Tools, the example below shows how rendering only happens to components with data that had changed. 19 | 20 | ![react-subscriber-render-performance](https://user-images.githubusercontent.com/4285261/160267333-9048ddb8-3eaa-456a-bfa1-6a14567de911.gif) 21 | 22 | ## Installation 23 | 24 | ```bash 25 | npm install react-subscribe-context 26 | ``` 27 | 28 | ```bash 29 | yarn add react-subscribe-context 30 | ``` 31 | 32 | ## Usage/Examples 33 | 34 | ### Setup 35 | 36 | ```tsx 37 | // SpiderManContext.ts 38 | import { createSubscriberContext } from "react-subscribe-context"; 39 | 40 | const initialState = { 41 | user: { 42 | name: { 43 | first: "Peter", 44 | last: "Parker", 45 | }, 46 | }, 47 | movieCounter: 9, 48 | }; 49 | 50 | export const { 51 | Context: SpiderManContext, 52 | Provider: SpiderManProvider, // Note: This is not the same as what Context.Provider returns 53 | } = createSubscriberContext({ initialState }); 54 | 55 | // alternative way 56 | export const [SpiderManContext, SpiderManProvider] = createSubscriberContext({ initialState }); 57 | ``` 58 | 59 | ```tsx 60 | // App.tsx 61 | import { MovieCounterComponent } from "path/to/MovieCounterComponent"; 62 | import { NameComponent } from "path/to/NameComponent"; 63 | import { SpiderManProvider } from "path/to/SpiderManContext"; 64 | 65 | const App = (): ReactElement => { 66 | return ( 67 | 68 |
69 | 70 | 71 |
72 |
73 | ); 74 | }; 75 | ``` 76 | 77 | ### Basic usage 78 | 79 | ```tsx 80 | // MovieCounterComponent.tsx 81 | import { useSubscribe } from "react-subscribe-context"; 82 | import { SpiderManContext } from "path/to/SpiderManContext"; 83 | 84 | export const MovieCounterComponent = (): ReactElement => { 85 | const [movieCounter, setMovieCounter] = useSubscribe(SpiderManContext, "movieCounter"); 86 | // alternative way 87 | const { value: movieCounter, setValue: setMovieCounter } = useSubscribe( 88 | SpiderManContext, 89 | "movieCounter" 90 | ); 91 | 92 | const handleClickCounter = () => { 93 | setMovieCounter(movieCounter + 1); 94 | }; 95 | 96 | return ; 97 | }; 98 | ``` 99 | 100 | ### Subscribing to nested object values 101 | 102 | These components will subscribe to `first` and `last` value changes. Even if the `name` object itself changes, the components will not rerender unless the `first` or `last` values are different. The examples below show two different ways of subscribing to a nested value. 103 | 104 | ```tsx 105 | // FirstNameComponent.tsx 106 | import { useSubscribe } from "react-subscribe-context"; 107 | import { SpiderManContext } from "path/to/SpiderManContext"; 108 | 109 | export const FirstNameComponent = (): ReactElement => { 110 | const [user] = useSubscribe(SpiderManContext, "user"); 111 | // alternative way 112 | const { value: user } = useSubscribe(SpiderManContext, "user"); 113 | 114 | const { 115 | name: { first }, 116 | } = user; 117 | 118 | return
{first}
; 119 | }; 120 | ``` 121 | 122 | ```tsx 123 | // LastNameComponent.tsx 124 | import { useSubscribe } from "react-subscribe-context"; 125 | import { SpiderManContext } from "path/to/SpiderManContext"; 126 | 127 | export const LastNameComponent = (): ReactElement => { 128 | const [state] = useSubscribe(SpiderManContext); 129 | // alternative way 130 | const { state } = useSubscribe(SpiderManContext); 131 | 132 | const { 133 | user: { 134 | name: { last }, 135 | }, 136 | } = state; 137 | 138 | return
{last}
; 139 | }; 140 | ``` 141 | 142 | ```tsx 143 | // NameComponent.tsx 144 | import { ReactElement } from "react"; 145 | import { useSubscribe } from "react-subscribe-context"; 146 | import { SpiderManContext } from "path/to/SpiderManContext"; 147 | import { FirstNameComponent } from "path/to/FirstNameComponent"; 148 | import { LastNameComponent } from "path/to/LastNameComponent"; 149 | 150 | type Name = { first: string; last: string }; 151 | 152 | const spiderManNames: Name[] = [ 153 | { first: "Peter", last: "Parker" }, 154 | { first: "Peter", last: "Porker" }, 155 | { first: "Peni", last: "Parker" }, 156 | { first: "Miles", last: "Morales" }, 157 | ]; 158 | 159 | const getRandomSpiderManName = (currentName: Name) => { 160 | let randomName: Name = spiderManNames[0]; 161 | 162 | do { 163 | randomName = spiderManNames[Math.floor(Math.random() * spiderManNames.length)]; 164 | } while (currentName.first === randomName.first && currentName.last === randomName.last); 165 | 166 | return randomName; 167 | }; 168 | 169 | export const NameComponent = (): ReactElement => { 170 | const [, setContextState] = useSubscribe(SpiderManContext); 171 | 172 | const handleClickRandomizeName = () => { 173 | setContextState((prevState) => { 174 | let { 175 | user: { name }, 176 | } = prevState; 177 | 178 | const randomSpiderManName = getRandomSpiderManName(name); 179 | 180 | return { 181 | ...prevState, 182 | user: { 183 | ...prevState.user, 184 | name: randomSpiderManName, 185 | }, 186 | }; 187 | }); 188 | }; 189 | 190 | return ( 191 |
192 | 193 | 194 | 195 |
196 | ); 197 | }; 198 | ``` 199 | 200 | ### Accessing state without subscribing to a value 201 | 202 | ```tsx 203 | // NameComponent.tsx 204 | import { useSubscribe } from "react-subscribe-context"; 205 | import { SpiderManContext } from "path/to/SpiderManContext"; 206 | import { FirstNameComponent } from "path/to/FirstNameComponent"; 207 | import { LastNameComponent } from "path/to/LastNameComponent"; 208 | 209 | export const NameComponent = (): ReactElement => { 210 | const [, , contextControl] = useSubscribe(SpiderManContext); 211 | // alternative way 212 | const { contextControl } = useSubscribe(SpiderManContext); 213 | // another alternative way 214 | const contextControl = React.useContext(SpiderManContext); 215 | 216 | const { getState, setState } = contextControl; 217 | 218 | const handleClickRandomizeName = () => { 219 | setContextState((prevState) => { 220 | let { 221 | user: { name }, 222 | } = prevState; 223 | 224 | const randomSpiderManName = getRandomSpiderManName(name); 225 | 226 | return { 227 | ...prevState, 228 | user: { 229 | ...prevState.user, 230 | name: randomSpiderManName, 231 | }, 232 | }; 233 | }); 234 | }; 235 | 236 | return ( 237 |
238 | 239 | 240 | 241 |
242 | ); 243 | }; 244 | ``` 245 | 246 | ## Adding resuable actions 247 | 248 | ```tsx 249 | // SpiderManContext.ts 250 | import { createSubscriberContext, BaseContextControl } from "react-subscribe-context"; 251 | 252 | const initialState = { 253 | user: { 254 | name: { 255 | first: "Peter", 256 | last: "Parker", 257 | }, 258 | }, 259 | movieCounter: 9, 260 | }; 261 | 262 | const createActions = (baseContextControl: BaseContextControl) => { 263 | const { setValue } = baseContextControl; 264 | 265 | return { 266 | incrementMovieCounter: () => { 267 | setValue("movieCounter", (movieCounter) => movieCounter + 1); 268 | }, 269 | }; 270 | }; 271 | 272 | export const [SpiderManContext, SpiderManProvider] = createSubscriberContext({ 273 | initialState, 274 | createActions, 275 | }); 276 | ``` 277 | 278 | ### Using actions 279 | 280 | ```tsx 281 | // MovieCounterComponent.tsx 282 | import { useSubscribe } from "react-subscribe-context"; 283 | import { SpiderManContext } from "path/to/SpiderManContext"; 284 | 285 | export const MovieCounterComponent = (): ReactElement => { 286 | const { value: movieCounter, actions } = useSubscribe(SpiderManContext, "movieCounter"); 287 | 288 | return ; 289 | }; 290 | ``` 291 | 292 | ## ContextControl Reference 293 | 294 | The ContextControl object holds functions that allows you to get and set values of your state. This is a great way to manage your state without subscribing to a value. 295 | 296 | #### Access via useContext 297 | 298 | ```tsx 299 | const contextControl = useContext(MyControlContext); 300 | ``` 301 | 302 | #### Access via useSubscribe 303 | 304 | ```tsx 305 | const [state, setState, contextControl] = useSubscribe(MyControlContext); 306 | const { state, setState, contextControl } = useSubscribe(MyControlContext); 307 | const [value, setValue, contextControl] = useSubscribe(MyControlContext, "key"); 308 | const { value, setValue, contextControl } = useSubscribe(MyControlContext, "key"); 309 | ``` 310 | 311 | ### ContextControl functions 312 | 313 | #### getValue(key) 314 | 315 | Returns a value from your state based on the given key. 316 | 317 | | Parameter | Type | Description | 318 | | :-------- | :------- | :------------------------------------ | 319 | | `key` | `string` | **Required**. Field key of your state | 320 | 321 | #### getState() 322 | 323 | Returns the state of your context 324 | 325 | #### setValue(key, nextValue) 326 | 327 | Sets a value in your state for the given field key. 328 | 329 | | Parameter | Type | Description | 330 | | :---------- | :---------------------------------------------------------------------- | :------------------------------------------------- | 331 | | `key` | `string` | **Required**. Field key of your state | 332 | | `nextValue` | `typeof state[key]` | **Required**. New value for `state[key]` | 333 | | `nextValue` | `(currValue: state[key], currState: typeof state) => typeof state[key]` | **Required**. Function that returns the next value | 334 | 335 | #### setState(nextState) 336 | 337 | Sets values of your state 338 | 339 | | Parameter | Type | Description | 340 | | :---------- | :--------------------------------------------------- | :------------------------------------------------- | 341 | | `nextState` | `Partial` | **Required**. Next state | 342 | | `nextState` | `(currState: typeof state) => Partial` | **Required**. Function that returns the next state | 343 | 344 | #### actions 345 | 346 | Holds the actions created by the developer from the create subscriber context stage. 347 | 348 | ## Demo 349 | 350 | Here's a [demo](https://stoic-kirch-0be43f.netlify.app/). 351 | 352 | Be sure to turn on render highlighting with your [React Dev Tool](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) to see the differences between rendering performances between each example. 353 | --------------------------------------------------------------------------------