diff --git a/starlette/middleware/base.py b/starlette/middleware/base.py
index ea5afb2..67d35cc 100644
--- a/starlette/middleware/base.py
+++ b/starlette/middleware/base.py
@@ -29,7 +29,7 @@ class BaseHTTPMiddleware:
         loop = asyncio.get_event_loop()
         queue = asyncio.Queue()  # type: asyncio.Queue
 
-        scope = dict(request)
+        scope = request.scope
         receive = request.receive
         send = queue.put
 
diff --git a/starlette/requests.py b/starlette/requests.py
index 44433a0..be05f29 100644
--- a/starlette/requests.py
+++ b/starlette/requests.py
@@ -18,8 +18,22 @@ class ClientDisconnect(Exception):
     pass
 
 
-class State:
-    pass
+class State(object):
+    def __init__(self, state: typing.Dict = {}):
+        super(State, self).__setattr__("_state", state)
+
+    def __setattr__(self, key: typing.Any, value: typing.Any) -> None:
+        self._state[key] = value
+
+    def __getattr__(self, key: typing.Any) -> typing.Any:
+        try:
+            return self._state[key]
+        except KeyError:
+            message = "'{}' object has no attribute '{}'"
+            raise AttributeError(message.format(self.__class__.__name__, key))
+
+    def __delattr__(self, key: typing.Any) -> None:
+        del self._state[key]
 
 
 class HTTPConnection(Mapping):
@@ -30,42 +44,42 @@ class HTTPConnection(Mapping):
 
     def __init__(self, scope: Scope, receive: Receive = None) -> None:
         assert scope["type"] in ("http", "websocket")
-        self._scope = scope
+        self.scope = scope
 
     def __getitem__(self, key: str) -> str:
-        return self._scope[key]
+        return self.scope[key]
 
     def __iter__(self) -> typing.Iterator[str]:
-        return iter(self._scope)
+        return iter(self.scope)
 
     def __len__(self) -> int:
-        return len(self._scope)
+        return len(self.scope)
 
     @property
     def app(self) -> typing.Any:
-        return self._scope["app"]
+        return self.scope["app"]
 
     @property
     def url(self) -> URL:
         if not hasattr(self, "_url"):
-            self._url = URL(scope=self._scope)
+            self._url = URL(scope=self.scope)
         return self._url
 
     @property
     def headers(self) -> Headers:
         if not hasattr(self, "_headers"):
-            self._headers = Headers(scope=self._scope)
+            self._headers = Headers(scope=self.scope)
         return self._headers
 
     @property
     def query_params(self) -> QueryParams:
         if not hasattr(self, "_query_params"):
-            self._query_params = QueryParams(self._scope["query_string"])
+            self._query_params = QueryParams(self.scope["query_string"])
         return self._query_params
 
     @property
     def path_params(self) -> dict:
-        return self._scope.get("path_params", {})
+        return self.scope.get("path_params", {})
 
     @property
     def cookies(self) -> typing.Dict[str, str]:
@@ -82,38 +96,41 @@ class HTTPConnection(Mapping):
 
     @property
     def client(self) -> Address:
-        host, port = self._scope.get("client") or (None, None)
+        host, port = self.scope.get("client") or (None, None)
         return Address(host=host, port=port)
 
     @property
     def session(self) -> dict:
         assert (
-            "session" in self._scope
+            "session" in self.scope
         ), "SessionMiddleware must be installed to access request.session"
-        return self._scope["session"]
+        return self.scope["session"]
 
     @property
     def auth(self) -> typing.Any:
         assert (
-            "auth" in self._scope
+            "auth" in self.scope
         ), "AuthenticationMiddleware must be installed to access request.auth"
-        return self._scope["auth"]
+        return self.scope["auth"]
 
     @property
     def user(self) -> typing.Any:
         assert (
-            "user" in self._scope
+            "user" in self.scope
         ), "AuthenticationMiddleware must be installed to access request.user"
-        return self._scope["user"]
+        return self.scope["user"]
 
     @property
     def state(self) -> State:
-        if "state" not in self._scope:
-            self._scope["state"] = State()
-        return self._scope["state"]
+        if not hasattr(self, "_state"):
+            # Ensure 'state' has an empty dict if it's not already populated.
+            self.scope.setdefault("state", {})
+            # Create a state instance with a reference to the dict in which it should store info
+            self._state = State(self.scope["state"])
+        return self._state
 
     def url_for(self, name: str, **path_params: typing.Any) -> str:
-        router = self._scope["router"]
+        router = self.scope["router"]
         url_path = router.url_path_for(name, **path_params)
         return url_path.make_absolute_url(base_url=self.url)
 
@@ -132,7 +149,7 @@ class Request(HTTPConnection):
 
     @property
     def method(self) -> str:
-        return self._scope["method"]
+        return self.scope["method"]
 
     @property
     def receive(self) -> Receive:

mypy errors when accessing Request.state
From https://www.starlette.io/requests/:
> Other state
>
> If you want to store additional information on the request you can do so using request.state.
>
> For example:
>
> request.state.time_started = time.time()

This causes mypy errors since any accessed/set attributes aren't present in the `State` class. As far as I can tell, there isn't a good way to tell mypy to expect certain attributes to potentially be set.

I could imagine solutions that somehow make `Request.state` generic, and those would probably be the most type-safe, but that seems like it would take a lot of work for little gain, and could make the API a little uglier.

As a workaround, I propose adding a `def __getattr__` in an `if typing.TYPE_CHECKING:` block within the class definition. This will silence the mypy errors without changing anything about how the class behaves during runtime.

One counter-argument to this approach would be that this would prevent state attribute accesses from being called out by mypy even if you want the warnings. But then you'd have to add `# type: ignore` to disable it, and would lose any ability to check the continued validity going forward. Given the suggested usage pattern, I just don't see much value to keeping these errors (though I could be convinced otherwise).

Alternatively, the state could be replaced by a `Dict[str, Any]` (or something that behaves more like one anyway), or given one as an attribute.
diff --git a/tests/middleware/test_base.py b/tests/middleware/test_base.py
index 04fa7d6..09a1d0b 100644
--- a/tests/middleware/test_base.py
+++ b/tests/middleware/test_base.py
@@ -84,3 +84,46 @@ def test_middleware_decorator():
     response = client.get("/homepage")
     assert response.text == "Homepage"
     assert response.headers["Custom"] == "Example"
+
+
+def test_state_data_across_multiple_middlewares():
+    expected_value1 = "foo"
+    expected_value2 = "bar"
+
+    class aMiddleware(BaseHTTPMiddleware):
+        async def dispatch(self, request, call_next):
+            request.state.foo = expected_value1
+            print("a", dict(request))
+            response = await call_next(request)
+            return response
+
+    class bMiddleware(BaseHTTPMiddleware):
+        async def dispatch(self, request, call_next):
+            request.state.bar = expected_value2
+            print("b", dict(request))
+            response = await call_next(request)
+            response.headers["X-State-Foo"] = request.state.foo
+            return response
+
+    class cMiddleware(BaseHTTPMiddleware):
+        async def dispatch(self, request, call_next):
+            print("c", dict(request))
+            response = await call_next(request)
+            print("c", dict(request))
+            response.headers["X-State-Bar"] = request.state.bar
+            return response
+
+    app = Starlette()
+    app.add_middleware(aMiddleware)
+    app.add_middleware(bMiddleware)
+    app.add_middleware(cMiddleware)
+
+    @app.route("/")
+    def homepage(request):
+        return PlainTextResponse("OK")
+
+    client = TestClient(app)
+    response = client.get("/")
+    assert response.text == "OK"
+    assert response.headers["X-State-Foo"] == expected_value1
+    assert response.headers["X-State-Bar"] == expected_value2
diff --git a/tests/test_requests.py b/tests/test_requests.py
index 270181e..03c74c4 100644
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -2,7 +2,7 @@ import asyncio
 
 import pytest
 
-from starlette.requests import ClientDisconnect, Request
+from starlette.requests import ClientDisconnect, Request, State
 from starlette.responses import JSONResponse, Response
 from starlette.testclient import TestClient
 
@@ -240,11 +240,25 @@ def test_request_is_disconnected():
     assert disconnected_after_response
 
 
+def test_request_state_object():
+    scope = {"state": {"old": "foo"}}
+
+    s = State(scope["state"])
+
+    s.new = "value"
+    assert s.new == "value"
+
+    del s.new
+
+    with pytest.raises(AttributeError):
+        s.new
+
+
 def test_request_state():
     async def app(scope, receive, send):
         request = Request(scope, receive)
         request.state.example = 123
-        response = JSONResponse({"state.example": request["state"].example})
+        response = JSONResponse({"state.example": request.state.example})
         await response(scope, receive, send)
 
     client = TestClient(app)
diff --git a/tests/test_responses.py b/tests/test_responses.py
index c7ca0ac..f90bde4 100644
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -8,11 +8,11 @@ from starlette.background import BackgroundTask
 from starlette.requests import Request
 from starlette.responses import (
     FileResponse,
+    JSONResponse,
     RedirectResponse,
     Response,
     StreamingResponse,
     UJSONResponse,
-    JSONResponse,
 )
 from starlette.testclient import TestClient
 

pip install -e .[full]
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
aiofiles==24.1.0
aiosqlite==0.21.0
autoflake==2.3.1
babel==2.17.0
backrefs==5.8
black==25.1.0
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
coverage==7.8.0
databases==0.9.0
exceptiongroup==1.2.2
ghp-import==2.1.0
graphene==3.4.3
graphql-core==3.2.6
graphql-relay==3.2.0
greenlet==3.1.1
idna==3.10
importlib_metadata==8.6.1
iniconfig==2.1.0
isort==6.0.1
itsdangerous==2.2.0
Jinja2==3.1.6
Markdown==3.7
MarkupSafe==3.0.2
mergedeep==1.3.4
mkdocs==1.6.1
mkdocs-get-deps==0.2.0
mkdocs-material==9.6.10
mkdocs-material-extensions==1.3.1
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
paginate==0.5.7
pathspec==0.12.1
platformdirs==4.3.7
pluggy==1.5.0
pyflakes==3.3.1
Pygments==2.19.1
pymdown-extensions==10.14.3
pytest==8.3.5
pytest-cov==6.0.0
python-dateutil==2.9.0.post0
python-multipart==0.0.20
PyYAML==6.0.2
pyyaml_env_tag==0.1
requests==2.32.3
six==1.17.0
SQLAlchemy==2.0.40
-e git+https://github.com/encode/starlette.git@03bfdbc674975a2149b034f32825001e15182f13#egg=starlette
tomli==2.2.1
typing_extensions==4.13.0
ujson==5.10.0
urllib3==2.3.0
watchdog==6.0.0
zipp==3.21.0

name: starlette
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - aiofiles==24.1.0
      - aiosqlite==0.21.0
      - autoflake==2.3.1
      - babel==2.17.0
      - backrefs==5.8
      - black==25.1.0
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - click==8.1.8
      - colorama==0.4.6
      - coverage==7.8.0
      - databases==0.9.0
      - exceptiongroup==1.2.2
      - ghp-import==2.1.0
      - graphene==3.4.3
      - graphql-core==3.2.6
      - graphql-relay==3.2.0
      - greenlet==3.1.1
      - idna==3.10
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - isort==6.0.1
      - itsdangerous==2.2.0
      - jinja2==3.1.6
      - markdown==3.7
      - markupsafe==3.0.2
      - mergedeep==1.3.4
      - mkdocs==1.6.1
      - mkdocs-get-deps==0.2.0
      - mkdocs-material==9.6.10
      - mkdocs-material-extensions==1.3.1
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - paginate==0.5.7
      - pathspec==0.12.1
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pyflakes==3.3.1
      - pygments==2.19.1
      - pymdown-extensions==10.14.3
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - python-dateutil==2.9.0.post0
      - python-multipart==0.0.20
      - pyyaml==6.0.2
      - pyyaml-env-tag==0.1
      - requests==2.32.3
      - six==1.17.0
      - sqlalchemy==2.0.40
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - ujson==5.10.0
      - urllib3==2.3.0
      - watchdog==6.0.0
      - zipp==3.21.0
prefix: /opt/conda/envs/starlette

tests/middleware/test_base.py::test_state_data_across_multiple_middlewares tests/test_requests.py::test_request_state_object
tests/test_responses.py::test_head_method
tests/middleware/test_base.py::test_custom_middleware tests/middleware/test_base.py::test_middleware_decorator tests/test_requests.py::test_request_url tests/test_requests.py::test_request_query_params tests/test_requests.py::test_request_headers tests/test_requests.py::test_request_client tests/test_requests.py::test_request_body tests/test_requests.py::test_request_stream tests/test_requests.py::test_request_form_urlencoded tests/test_requests.py::test_request_body_then_stream tests/test_requests.py::test_request_stream_then_body tests/test_requests.py::test_request_json tests/test_requests.py::test_request_scope_interface tests/test_requests.py::test_request_without_setting_receive tests/test_requests.py::test_request_disconnect tests/test_requests.py::test_request_is_disconnected tests/test_requests.py::test_request_state tests/test_requests.py::test_request_cookies tests/test_requests.py::test_chunked_encoding tests/test_responses.py::test_text_response tests/test_responses.py::test_bytes_response tests/test_responses.py::test_ujson_response tests/test_responses.py::test_json_none_response tests/test_responses.py::test_redirect_response tests/test_responses.py::test_streaming_response tests/test_responses.py::test_sync_streaming_response tests/test_responses.py::test_response_headers tests/test_responses.py::test_response_phrase tests/test_responses.py::test_file_response tests/test_responses.py::test_file_response_with_directory_raises_error tests/test_responses.py::test_file_response_with_missing_file_raises_error tests/test_responses.py::test_set_cookie tests/test_responses.py::test_delete_cookie tests/test_responses.py::test_populate_headers
starlette/middleware/base.py starlette/requests.py
starlette/middleware/base.py starlette/requests.py